Testing Python Code

Check your code automatically with unit tests: assert, pytest, testing for exceptions, fixtures, parametrised tests and unittest.

Lesson 45 of 107Advanced17 min read

Every time you change a program, you risk breaking something that used to work. You could check everything by hand after each change, running the program and trying different inputs, but that's slow, boring and easy to skip. As a project grows, it becomes impossible.

Automated tests do the checking for you. A test is a small function that runs a piece of your code with known inputs and checks the result is what you expect. A whole suite of tests runs in seconds, so you can change code confidently: if something breaks, a test tells you exactly what and where.

In this lesson you'll write tests with pytest, the most popular Python testing tool, read failures, test that errors are raised correctly, check many cases at once, share setup code with fixtures, and see Python's built-in unittest module, which you'll find in many existing projects.

assert and your first test

The assert statement checks that something is true, and raises an AssertionError if it isn't:

Python
# pricing.py
def apply_discount(price, percent):
    return round(price * (1 - percent / 100), 2)

# test_pricing.py
from pricing import apply_discount

def test_ten_percent_off():
    assert apply_discount(50, 10) == 45.0

pytest finds tests automatically: files named test_*.py and functions whose names start with test_. Install it with pip install pytest inside a virtual environment, then run pytest in the project folder.

Reading results

pytest prints a dot for each passing test and an F for each failure. For a failure, it shows the test, the line that failed and the values involved:

Text
>       assert apply_discount(80, 25) == 65
E       assert 60.0 == 65

The E line shows the actual value (60.0) next to the expected one (65). Either the code is wrong or the test's expectation is: here, 25% off 80 really is 60, so the test needs fixing. Useful options: pytest -v lists every test by name, and pytest -k discount runs only tests whose names contain "discount".

Exceptions and many cases

Test that bad input raises the right error with pytest.raises:

Python
import pytest

def test_negative_percent_is_rejected():
    with pytest.raises(ValueError):
        apply_discount(50, -10)

To check many inputs without copying the test, use @pytest.mark.parametrize. It runs the same test function once for each set of values, and reports each one separately, so you see exactly which case fails.

A parametrised test in full

Here is parametrize written out. The first argument names the parameters, and the list supplies one tuple of values per test case:

Python
import pytest
from pricing import apply_discount

@pytest.mark.parametrize("price, percent, expected", [
    (100, 0, 100.0),
    (100, 25, 75.0),
    (19.99, 10, 17.99),
    (50, 100, 0.0),
])
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected

pytest reports these as four separate tests, labelled with their values, so a failure tells you exactly which case broke. When comparing results of float arithmetic that isn't rounded, use pytest.approx(expected) instead of ==.

Fixtures and unittest

A fixture is a function marked @pytest.fixture that prepares something a test needs, such as sample data. Tests receive it by naming it as a parameter. pytest also provides built-in fixtures, such as tmp_path, a temporary folder for testing code that writes files.

Python's built-in unittest module works without installing anything. Tests are methods in a class that inherits from unittest.TestCase, using methods like self.assertEqual(a, b). pytest can run unittest tests too, so many projects mix both.

Python Testing Syntax

pytest essentials

import pytest

def test_something():
    assert result == expected

def test_error():
    with pytest.raises(ValueError):
        risky()

@pytest.mark.parametrize("a, b, expected", [(1, 2, 3), (0, 0, 0)])
def test_add(a, b, expected):
    assert add(a, b) == expected

@pytest.fixture
def sample():
    return {...}

Python Testing Summary

  • A test runs your code with known input and uses assert to check the result.
  • pytest finds test_*.py files and test_ functions; run them with pytest.
  • Failures show the actual and expected values on the E line.
  • pytest.raises checks for errors; @pytest.mark.parametrize runs one test with many inputs.
  • Fixtures prepare shared test data; unittest is the built-in alternative using TestCase classes.

Python Testing Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Testing a function with pytest

Python
# grades.py
def grade(score):
    if not 0 <= score <= 100:
        raise ValueError("score must be 0 to 100")
    if score >= 70:
        return "A"
    if score >= 50:
        return "B"
    return "C"

# test_grades.py
import pytest
from grades import grade

@pytest.mark.parametrize("score, expected", [
    (100, "A"), (70, "A"), (69, "B"), (50, "B"), (49, "C"), (0, "C"),
])
def test_grade_boundaries(score, expected):
    assert grade(score) == expected

def test_score_above_100_is_rejected():
    with pytest.raises(ValueError):
        grade(101)
Output
$ pytest -v
test_grades.py::test_grade_boundaries[100-A] PASSED
test_grades.py::test_grade_boundaries[70-A] PASSED
test_grades.py::test_grade_boundaries[69-B] PASSED
test_grades.py::test_grade_boundaries[50-B] PASSED
test_grades.py::test_grade_boundaries[49-C] PASSED
test_grades.py::test_grade_boundaries[0-C] PASSED
test_grades.py::test_score_above_100_is_rejected PASSED
7 passed in 0.02s
Line-by-line explanation
  1. Lines 15 to 17Six cases, chosen right at the edges of each grade, where bugs usually hide.
  2. Lines 18 to 19One test function runs six times, once per case.
  3. Lines 21 to 23Checks that invalid input is rejected with the right exception.
Example 2

A failing test

Python
# basket.py
def basket_total(prices):
    total = 0
    for p in prices[1:]:
        total += p
    return total

# test_basket.py
from basket import basket_total

def test_total_adds_every_price():
    assert basket_total([2, 3, 5]) == 10
Output
$ pytest
F                                                       [100%]
    def test_total_adds_every_price():
>       assert basket_total([2, 3, 5]) == 10
E       assert 8 == 10
E        +  where 8 = basket_total([2, 3, 5])
1 failed in 0.03s
Line-by-line explanation
  1. Line 4The bug: prices[1:] skips the first item.
  2. Line 12The test expects 10. The E lines in the output show the function returned 8, which points straight at the missing 2. Changing prices[1:] to prices makes the test pass.
Example 3

A fixture and a temporary file

Python
import pytest
from notes import save_note, load_notes

@pytest.fixture
def notes_file(tmp_path):
    return tmp_path / "notes.txt"

def test_saved_notes_can_be_loaded(notes_file):
    save_note(notes_file, "Buy milk")
    save_note(notes_file, "Call Sam")
    assert load_notes(notes_file) == ["Buy milk", "Call Sam"]
Output
$ pytest
.                                                       [100%]
1 passed in 0.02s
Line-by-line explanation
  1. Lines 4 to 6The fixture builds a path inside pytest's temporary folder, which is deleted afterwards.
  2. Line 8Naming notes_file as a parameter tells pytest to run the fixture and pass in its result.
  3. Lines 9 to 11The test writes real files without touching your own folders.

Common Python Testing Mistakes

Tests that test nothing

Don't
def test_grade():
    grade(85)           # no assert: always passes
Do
def test_grade():
    assert grade(85) == "A"

Comparing floats exactly

Don't
assert 0.1 + 0.2 == 0.3
Do
assert 0.1 + 0.2 == pytest.approx(0.3)

Test names pytest can't find

Don't
# file: grades_tests.py
def check_grade():
    ...
Do
# file: test_grades.py
def test_grade():
    ...

Python Testing in Real Projects

Tests in real teams

Professional projects run their test suites automatically on every pull request, often with GitHub Actions, and won't merge code that breaks a test. Open-source libraries like requests and Django have thousands of tests. Many developers practise test-driven development: write a failing test that describes the feature, write just enough code to pass it, then tidy up.

Python Testing FAQ

What is the difference between pytest and unittest?

unittest is built into Python and uses classes with methods such as assertEqual. pytest is a third-party tool that lets you write tests as plain functions with plain assert, gives more detailed failure messages and has fixtures and plugins. pytest can also run existing unittest tests, so you can switch gradually.

What is a unit test?

A unit test checks one small piece of code, usually a single function or method, in isolation from databases, networks and other slow or unpredictable parts. Because unit tests are fast and focused, you can run hundreds of them after every change. Integration tests, by contrast, check that several parts work together.

Should I use assert in normal program code?

Use assert freely in tests. In application code, don't rely on it for validating user input, because Python skips every assert statement when run with the -O optimisation flag. Raise a proper exception such as ValueError instead.

How do I test code that calls a web API or a database?

Replace the slow or external part with a fake during the test, using unittest.mock or pytest's monkeypatch fixture, so the test is fast and gives the same result every time. The mocking and fixtures lesson shows these techniques step by step.

How many tests should I write?

Aim to cover each behaviour that matters: the normal case, boundary values and error handling. Coverage tools such as pytest-cov show which lines were never run by any test, which is a useful hint, but 100% coverage does not prove the code is correct.

Python Testing Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the test so it checks that add(2, 3) returns 5.

def test_add():
     add(2, 3) == 5
Multiple choiceeasy

Which file name will pytest discover automatically?

Multiple choicemedium

How do you test that a function raises ValueError?

Predict the outputmedium

A parametrized test has 4 sets of values. How many results does pytest report for it?

Multiple choicehard

Where do bugs most often hide, and so where should test cases focus?

Python Testing Quiz

5 questions to check what you learned.

  1. What does an automated test do?

    • Runs code with known inputs and checks the result automatically
    • Speeds up the program
    • Formats code
    • Installs packages
  2. In pytest output, what does F mean?

    • Finished
    • A fixture ran
    • A test failed
    • A file was skipped
  3. What is a fixture?

    • A broken test
    • A type of assert
    • A test file
    • A function that prepares data or resources that tests receive as parameters
  4. How do you compare floats safely in a pytest assert?

    • pytest.approx(expected)
    • round both sides to 0 decimals
    • str(a) == str(b)
    • You can't
  5. You've found a bug. What's a good first step?

    • Delete the related tests
    • Write a test that reproduces the bug, then fix the code until it passes
    • Rewrite the whole module
    • Ignore it if other tests pass