Skip to content

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
   ▲  E2E Tests
   │  Fewer, slow, complex
   │
   │  Integration Tests
   │  Moderate number
   │
   ▼  Unit Tests
      Numerous, fast, reliable

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
import unittest

class MyTests(unittest.TestCase):
    def test_addition(self):
        self.assertEqual(1 + 1, 2)

if __name__ == '__main__':
    unittest.main()

Run it with:

1
python -m unittest my_test_file.py

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
def test_addition():
    assert 1 + 1 == 2

Run with:

1
pytest

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
import pytest

@pytest.fixture
def sample_data():
    return {"key": "value"}

def test_data_has_key(sample_data):
    assert "key" in sample_data

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
from unittest.mock import patch

@patch("requests.get")
def test_api_call(mock_get):
    mock_get.return_value.status_code = 200
    assert call_my_api().status_code == 200

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
@pytest.fixture
def db_connection():
    conn = setup_db()
    yield conn
    conn.close()

def test_query(db_connection):
    result = query_database(db_connection)
    assert result == expected

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
pip install coverage

Run with:

1
2
3
coverage run -m pytest
coverage report
coverage html  # Generates HTML report

Or with pytest-cov:

1
pytest --cov=my_package tests/

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
cookie-factory
├─── src
|    ├─── cookie_factory
|    |    ├─── __init__.py
|    |    ├─── __main__.py
|    |    ├─── cookie.py
|    |    └─── factory.py
├─── tests
|    ├─── test_cookie.py
│    └─── test_factory.py

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
cookie-factory
├─── src
|    ├─── cookie_factory
|    |    ├─── models
|    |    |    ├─── __init__.py
|    |    |    ├─── dough.py
|    |    |    └─── cookie.py
|    |    ├─── factory
|    |    |    ├─── __init__.py
|    |    |    ├─── machine.py
|    |    |    └─── factory.py
|    |    ├─── __init__.py
|    |    └─── __main__.py
├─── tests
|    ├─── models
|    |    ├─── test_dough.py
|    |    └─── test_cookie.py
|    ├─── factory
|    |    ├─── test_machine.py
|    |    └─── test_factory.py

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
[tox]
envlist = py39, py310, lint, format

[testenv]
deps = pytest
commands = pytest

[testenv:lint]
deps = flake8
commands = flake8 src/

[testenv:format]
deps = black
commands = black --check src/

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
tox

Run a specific environment:

1
tox -e <env name>  # for instance py39

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:

  1. Red – Write a test that fails.
  2. Green – Write just enough code to pass the test.
  3. 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
# test_math_utils.py
def test_add():
    assert add(1, 2) == 3

This test will fail if add doesn’t exist. Now implement the minimal function:

1
2
3
# math_utils.py
def add(a, b):
    return a + b

Re-run the test to confirm it passes:

1
pytest

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
test: add failing test for negative addition
feat: support negative numbers in add()
refactor: simplify addition logic

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

TDD is a discipline that takes practice. Start small, keep your cycles short, and let your tests guide your design.