Testing Your Python Code⚓
Software testing is a key component of professional software development. It allows developers to:
- Detect bugs early in the development lifecycle
- Prevent regressions when changing code
- Improve overall code quality and reliability
- Refactor and evolve software with confidence
Types of Software Tests⚓
- Unit Tests: Verify individual functions or methods in isolation.
- Integration Tests: Validate interactions between components/modules.
- System Tests: Check the behavior of the complete system (e.g as a black box).
- Acceptance Tests: Ensure the system meets business requirements.
- End-to-End (E2E) Tests: Simulate user scenarios from start to finish.
- Regression Tests: Ensure that previously fixed bugs don’t reappear.
- Performance/Load Tests: Evaluate responsiveness and scalability.
The Testing Pyramid⚓
The testing pyramid is a visual metaphor guiding test distribution:
1 2 3 4 5 6 7 8 |
|
Unit tests should form the base, with fewer integration and system tests above.
Unit Testing in Python⚓
In python, you should write unit tests for all exposed modules and functions of your project. This means every function/module/class/method, which a user can import (i.e the public part of your project software's API).
Those tests should be extensive, covering every use case (e.g every branch in your implementation) at the very least, test special inputs (e.g 0 value when a division is used), error use cases as well (to check pertinent errors are raised as expected).
Built-in unittest⚓
Python includes the unittest module as part of its standard library.
1 2 3 4 5 6 7 8 |
|
Run it with:
1 |
|
Using pytest⚓
pytest is the preferred testing framework due to its simplicity, powerful features, and wide adoption.
You simply need to write functions starting with a test_
prefix, and you can use the assert
operator to declare what to expect.
Basic example:
1 2 |
|
Run with:
1 |
|
Tip
You can also combine unittest TestCase
class, with pytest as the tesing framework. You will benefit from pytest test discovery and unittest code structure.
Pytest interest:
- Simple test discovery via naming convention (
test_*.py
), (test_
functions/methods) - Rich set of plugins and tools
- Minimal boilerplate required
Pytest Fixtures⚓
Fixtures allow setup/teardown of test resources. Those are resources (e.g variables) that will be set by a function before any test execution using it.
1 2 3 4 5 6 7 8 |
|
Note
The name you gave you fixture function is the one you should give to the parameter of the unit test, so it will be replaced at execution time by the output of the fixture.
Useful built-in fixtures: tmp_path
, monkeypatch
, etc.
Mocking and Patching⚓
To isolate components during testing, you can mock dependencies.
1 2 3 4 5 6 |
|
This example test intercepts requests.get
calls inside of it, and simply make them return an object with a statuc_code
attribute set to 200.
You can also use pytest-mock to simplify this.
Tip
When writing unit tests for a complex package with a tight integration (i.e a lot of components depending on one another), you should use mocking to test them in isolation, allowing you to test each individual component as if the dependent components were working as expected. Indeed, you will write other unit tests to test them in isolation anyway. If you don't, you might be writing integration tests, which are complementary.
Integration Testing in Python⚓
Integration tests check that different parts of your application work together correctly.
Example: testing a function that reads from a database and returns processed results.
1 2 3 4 5 6 7 8 9 |
|
These tests are slower and more complex but essential.
Test Coverage⚓
Test coverage measures how much of your code is executed by your tests. This measure can be applied to any type of tests: unit tests, integration tests.
Using coverage.py⚓
Install:
1 |
|
Run with:
1 2 3 |
|
Or with pytest-cov
:
1 |
|
Note
100% coverage is not a guarantee, but useful to guide test writing.
Tests structure⚓
Generally, all tests are located inside the tests/
directory at the root of your project. They are then separated from source code. There are multiple ways to organize your test files inside that directory, though depending on the types of tests, some organization could be easier to maintain and understand.
Unit tests structure⚓
Having one test file per public module in the source code, is generally straightforward (you can call it like the source code module, e.g test_<module>.py
). You then easily know where to find the tests of the different functions/classes/modules. Example:
1 2 3 4 5 6 7 8 9 10 |
|
If your source code has a deep hierarchy, you can simply mimick this hierarchy in the tests/
directory for the test files. For instance:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Integration tests structure⚓
In the case of integrations tests, as long as you organize them in test files within the tests/
directory, it should be fine.
Depending on the level of integration, test files could also be organized by source code modules, or organized by high-level functionality.
Using tox for Isolated Testing⚓
tox is a tool for automating testing in isolated environments, useful for supporting multiple Python versions and tools. It can be setup either via pyproject.toml
or tox.ini
file.
Example tox.ini
⚓
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
This configures 4 environments for tox:
- a testing environment with python 3.9 which executes the tests using pytest
- a testing environment with python 3.10 which executes the tests using pytest
- an environment with your project python version which executes the flake8 linter
- an environment with your project python version which executes the black formatter to check the code style
Run all environments:
1 |
|
Run a specific environment:
1 |
|
Tests automation⚓
We can, as for Code Style, automatize the tests so they are run whenever a commit is done (see pre-commit hook), or whenever a python file is saved (see File Watchers).
This simply means replacing (not recommended) or appending the linters/formatters commands in the File Watchers and pre-commit hook.
This can also be done server-side, using Continuous Integration, which we will cover in another guide. This is a very important first step when managing a Merge Request, to see that all tests pass, though you should also review the changes manually to make sure the tests are thorough, and your other guidelines are respected.
Test-Driven Development (TDD)⚓
Test-Driven Development (TDD) is a software development approach where you write tests before writing the actual code. It helps ensure your code is well-designed, tested, and refactorable from the start.
The TDD Cycle⚓
TDD follows a simple, repeatable cycle often described as Red → Green → Refactor:
- Red – Write a test that fails.
- Green – Write just enough code to pass the test.
- Refactor – Clean up the implementation while keeping the tests green.
Repeat this cycle for each small piece of functionality.
Example TDD Workflow in Python⚓
Using pytest
:
1 2 3 |
|
This test will fail if add
doesn’t exist. Now implement the minimal function:
1 2 3 |
|
Re-run the test to confirm it passes:
1 |
|
Then you can refactor if needed (e.g., add type annotations or improve naming) — ensuring the test still passes.
TDD Commit Workflow⚓
In a TDD process, you should commit frequently, ideally at the end of each TDD cycle:
- ✅ After writing a failing test and making it pass
- ✅ After each meaningful refactor
This keeps changes small, logical, and easy to understand.
Example commit messages:
1 2 3 |
|
Benefits of TDD⚓
- ✅ Higher test coverage and confidence
- 🧱 Forces modular, testable design
- ♻️ Encourages safe, incremental changes
- 🐛 Helps prevent regression bugs
- 🧠 Clear definition of done per feature
Common Pitfalls⚓
Pitfall | Why It’s Harmful |
---|---|
Writing multiple tests before implementation | Breaks feedback loop |
Skipping the refactor step | Leads to technical debt |
Writing implementation before the test | Defeats the purpose of TDD |
Committing failing tests | Avoid unless exploring or spiking |
Tools That Support TDD in Python⚓
- pytest
- unittest
- tox – automate multi-environment testing
- pre-commit – run tests before committing
- Continuous Integration – run your TDD tests on every push/MR
TDD is a discipline that takes practice. Start small, keep your cycles short, and let your tests guide your design.