API Testing & Quality Engineering
Hands-onBeginner 40 min· Automated tests · 6 of 19

Unit testing in Python with pytest

Write your first automated unit tests with pytest: simple asserts, testing for errors, parametrised tests for boundaries, and fixtures. You will test the Brew & Bean price and phone logic.

In this tutorial you will write automated unit tests for the Brew & Bean pricing code with pytest, Python's most popular test framework. You will learn how to write a test, run it, read a failure, test that errors are raised, run one test with many inputs (ideal for boundary values) and share set-up code with fixtures.

Prerequisites: Python 3.10 or newer, basic Python (functions, lists, dictionaries), and the pricing.py file from the lesson *Testing APIs by hand*. It is repeated here in short so you can follow along.

What is a unit test?

A unit test calls one small piece of code (a function) with known input and checks the output. It does not use the network, a database or a browser, so it runs in milliseconds. You can run hundreds in a second after every edit, which is why they sit at the bottom of the test pyramid.

Why pytest? The built-in unittest module works but needs classes and special assertion methods. pytest lets you write plain functions and plain assert statements, and prints very readable failure messages.

Step 1: set up

Terminal
mkdir brew_api
cd brew_api
python -m venv .venv
# Windows PowerShell:  .venv\Scripts\Activate.ps1
# macOS / Linux:       source .venv/bin/activate
pip install pytest

Creates a project folder with its own virtual environment and installs pytest.

Put pricing.py in this folder. This is the code under test:

pricing.py
MENU = {
    "espresso": {"name": "Espresso", "category": "coffee", "price": 120},
    "cappuccino": {"name": "Cappuccino", "category": "coffee", "price": 180},
    "cold-brew": {"name": "Cold Brew", "category": "coffee", "price": 220},
    "filter-coffee": {"name": "Filter Coffee", "category": "coffee", "price": 80},
    "masala-chai": {"name": "Masala Chai", "category": "tea", "price": 90},
    "croissant": {"name": "Butter Croissant", "category": "bakery", "price": 150},
    "banana-bread": {"name": "Banana Bread", "category": "bakery", "price": 130},
    "veg-sandwich": {"name": "Veg Grilled Sandwich", "category": "food", "price": 160},
}

MAX_QTY = 10


def calculate_total(items):
    """items is a list of dicts like {"item_id": "espresso", "qty": 2}."""
    if not items:
        raise ValueError("An order needs at least one item")
    total = 0
    for line in items:
        item_id = line["item_id"]
        qty = line["qty"]
        if item_id not in MENU:
            raise ValueError(f"Unknown item: {item_id}")
        if not 1 <= qty <= MAX_QTY:
            raise ValueError(f"Quantity must be between 1 and {MAX_QTY}")
        total += MENU[item_id]["price"] * qty
    return total


def is_valid_phone(phone):
    """Indian mobile number: 10 digits, starting with 6, 7, 8 or 9."""
    return len(phone) == 10 and phone.isdigit() and phone[0] in "6789"

Two functions to test: calculate_total (sums price x quantity and rejects bad input) and is_valid_phone.

Step 2: your first test

pytest finds files named test_*.py (or *_test.py), and inside them functions named test_*. Create test_pricing.py:

test_pricing.py
from pricing import calculate_total


def test_single_item():
    assert calculate_total([{"item_id": "espresso", "qty": 1}]) == 120

A test is a function whose name starts with test_. It passes if no assertion fails.

Terminal
pytest

Run from the brew_api folder.

Output (your header lines will differ)
============================ test session starts =============================
collected 1 item

test_pricing.py .                                                       [100%]

============================== 1 passed in 0.02s ==============================

The dot means one passing test. A test passes when it finishes without an exception. assert raises an exception when its condition is false, and that is all pytest needs.

Step 3: read a failure

Passing tests teach you little. Add a test that is wrong on purpose, run it, and read the failure:

test_pricing.py (temporary)
def test_several_items_wrong_on_purpose():
    items = [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}]
    assert calculate_total(items) == 360

The correct total is 2 x 120 + 150 = 390, so this should fail.

Output
    def test_several_items_wrong_on_purpose():
        items = [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}]
>       assert calculate_total(items) == 360
E       assert 390 == 360
E        +  where 390 = calculate_total([{'item_id': 'espresso', 'qty': 2}, {'item_id': 'croissant', 'qty': 1}])

test_pricing.py:9: AssertionError
=========================== short test summary info ===========================
FAILED test_pricing.py::test_several_items_wrong_on_purpose - assert 390 == 360

pytest shows the line that failed (marked with >), the actual value (390) beside the expected value (360), and the call that produced it. Reading this carefully is a skill: first decide whether the test or the code is wrong. Here the test is wrong, so fix the number to 390 and the test will pass.

Step 4: test that errors are raised

Good code refuses bad input. A test for that must pass when the function raises the right error. Use pytest.raises:

test_pricing.py
import pytest

from pricing import calculate_total


def test_empty_order_is_rejected():
    with pytest.raises(ValueError, match="at least one item"):
        calculate_total([])


def test_unknown_item_is_rejected():
    with pytest.raises(ValueError, match="Unknown item"):
        calculate_total([{"item_id": "pizza", "qty": 1}])

The test passes only if a ValueError is raised and its message matches the text. If no error is raised, the test fails.

match is a regular expression searched in the error message. It makes the test more precise: a ValueError for the wrong reason should not pass.

Step 5: many inputs with parametrize

Recall boundary value analysis: for quantity 1 to 10, test 0, 1, 10 and 11. Writing four near-identical tests is boring and bug-prone. @pytest.mark.parametrize runs one test function with several inputs, and reports each separately.

test_pricing.py
@pytest.mark.parametrize("qty", [0, 11, -1])
def test_quantity_out_of_range(qty):
    with pytest.raises(ValueError):
        calculate_total([{"item_id": "espresso", "qty": qty}])


@pytest.mark.parametrize("qty, expected", [(1, 120), (10, 1200)])
def test_quantity_limits_are_accepted(qty, expected):
    assert calculate_total([{"item_id": "espresso", "qty": qty}]) == expected

The first test runs 3 times (0, 11, -1); the second runs twice. Together they cover both edges and both sides of each edge.

Terminal
pytest -v

-v prints one line per test, including the parameters.

Output
test_pricing.py::test_quantity_out_of_range[0] PASSED
test_pricing.py::test_quantity_out_of_range[11] PASSED
test_pricing.py::test_quantity_out_of_range[-1] PASSED
test_pricing.py::test_quantity_limits_are_accepted[1-120] PASSED
test_pricing.py::test_quantity_limits_are_accepted[10-1200] PASSED

When a parametrised case fails, the bracket shows exactly which input broke, such as [11]. Add a new row to the list whenever you find a bug: the test grows with your knowledge.

Step 6: fixtures for shared set-up

If several tests need the same data, do not copy it. A fixture is a function that prepares something and hands it to any test that asks for it by name.

test_pricing.py
@pytest.fixture
def basket():
    return [{"item_id": "masala-chai", "qty": 2}, {"item_id": "banana-bread", "qty": 1}]


def test_basket_total(basket):
    assert calculate_total(basket) == 310

pytest sees that test_basket_total has a parameter called basket, runs the fixture and passes in its return value. 2 x 90 + 130 = 310.

Each test gets a fresh copy, so one test cannot accidentally change data for another. That is important: tests that depend on each other's leftovers are a major cause of flaky test suites. Later lessons use fixtures to start a test database and an API client.

Step 7: test the phone validator

test_pricing.py
@pytest.mark.parametrize("phone, valid", [
    ("9876543210", True),
    ("5876543210", False),
    ("987654321", False),
    ("98765432101", False),
    ("98765abcde", False),
    ("", False),
])
def test_phone(phone, valid):
    assert is_valid_phone(phone) is valid

Equivalence classes and boundaries for the phone rule: valid, wrong first digit, 9 digits, 11 digits, letters, empty.

The complete test file

Here is everything together. It must import pytest and the two functions at the top. Running pytest should show 16 passing tests.

test_pricing.py
import pytest

from pricing import calculate_total, is_valid_phone


def test_single_item():
    assert calculate_total([{"item_id": "espresso", "qty": 1}]) == 120


def test_several_items():
    items = [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}]
    assert calculate_total(items) == 390


def test_empty_order_is_rejected():
    with pytest.raises(ValueError, match="at least one item"):
        calculate_total([])


def test_unknown_item_is_rejected():
    with pytest.raises(ValueError, match="Unknown item"):
        calculate_total([{"item_id": "pizza", "qty": 1}])


@pytest.mark.parametrize("qty", [0, 11, -1])
def test_quantity_out_of_range(qty):
    with pytest.raises(ValueError):
        calculate_total([{"item_id": "espresso", "qty": qty}])


@pytest.mark.parametrize("qty, expected", [(1, 120), (10, 1200)])
def test_quantity_limits_are_accepted(qty, expected):
    assert calculate_total([{"item_id": "espresso", "qty": qty}]) == expected


@pytest.fixture
def basket():
    return [{"item_id": "masala-chai", "qty": 2}, {"item_id": "banana-bread", "qty": 1}]


def test_basket_total(basket):
    assert calculate_total(basket) == 310


@pytest.mark.parametrize("phone, valid", [
    ("9876543210", True),
    ("5876543210", False),
    ("987654321", False),
    ("98765432101", False),
    ("98765abcde", False),
    ("", False),
])
def test_phone(phone, valid):
    assert is_valid_phone(phone) is valid
Output
collected 16 items

test_pricing.py ................                                        [100%]

============================== 16 passed in 0.28s ==============================

The everyday test loop

  1. 1Pick one behaviour, such as 'quantity 11 is rejected'.
  2. 2Write the test first and run pytest. It should fail, which proves the test can fail.
  3. 3Change the code as little as needed to make it pass, and run again.
  4. 4Add the next case (a boundary or a negative input) and repeat.
  5. 5Finish by running the whole suite and reading any failure message carefully.

Useful pytest commands

Everyday commands

CommandWhat it does
pytestRun all tests in the current folder and below
pytest -vOne line per test
pytest test_pricing.pyRun one file
pytest test_pricing.py::test_phoneRun one test function
pytest -k phoneRun tests whose name contains 'phone'
pytest -xStop at the first failure
pytest --lfRe-run only the tests that failed last time
pytest -qQuieter output

Qualities of a good unit test

  1. It tests one behaviour, and its name says which.
  2. It is independent: it passes alone and in any order.
  3. It is fast and uses no network or real database.
  4. It has a clear arrange, act, assert shape: set up data, call the code, check the result.
  5. It would fail if the code were wrong. (Try breaking the code to check.)
  6. It does not repeat the code's own logic. Use hand-calculated expected values like 390, not a copy of the formula.

Prove the test can fail

A test that cannot fail is worse than no test. assert True, or checking a value you just set yourself, gives false confidence. After writing a test, change the code to be wrong (for example, change <= MAX_QTY to < MAX_QTY) and confirm the test goes red. Then put the code back.

How this comes up in interviews

Common questions: "What is the difference between a unit and an integration test?", "What is a fixture?", "How do you test that a function raises an exception?", "What is parametrisation and when do you use it?", "What is test coverage, and is 100% coverage good enough?" On the last one: coverage tells you which lines ran during tests, not whether the assertions are meaningful. The pytest-cov plugin can report it, but a high number with weak assertions still lets bugs through.

Prompt · Review my pytest tests
Review these pytest tests for [function or module name]. The code under test is below the tests.

Tests:
[paste test file]

Code:
[paste code]

Tell me: (1) which important cases are missing (boundaries, negative inputs, empty values), (2) any test that could never fail, (3) any duplicated tests that should be parametrised, and (4) suggest the missing tests as runnable pytest code. Do not change the code under test.

What to expect: Run every suggested test. If a suggested test fails, decide whether it found a real bug or the AI misunderstood the rules. Never accept tests that you do not understand.

Try it: Add your own tests

Add these to test_pricing.py, then run pytest: 1. A test that a Masala Chai x 1 costs 90 and a Veg Grilled Sandwich x 1 costs 160. 2. A parametrised test where each row is a (menu item, qty, expected total) triple for at least four different items. 3. A test that an order with a missing qty key raises KeyError. 4. Pick one line of calculate_total, change it so it is wrong, and check which of your tests catch it. Restore the code.

Try it: Find a bug with a test

Write apply_coupon(total, code) in pricing.py that gives 10% off if the code is FIRST10 and the total is at least 200, and otherwise returns the total unchanged. Write tests first for totals 199, 200 and 201, an unknown code and a lowercase first10. Decide what lowercase should do before you run them.

Key takeaways

  1. A unit test calls one small piece of code and asserts the result; pytest finds files named test_*.py and functions named test_*.
  2. Plain assert is enough. pytest explains failures clearly: read the expected and actual values.
  3. Use pytest.raises(ErrorType, match=...) to test that bad input is rejected.
  4. @pytest.mark.parametrize runs one test with many inputs. Use it for boundaries and equivalence classes.
  5. Fixtures prepare fresh shared data for tests that ask for them by name.
  6. Prove a test can fail by breaking the code, and keep tests independent, fast and focused.

Check yourself

Finishing the quiz marks the lesson as done.

1. How does pytest decide which functions to run as tests?

2. You want to check that calculate_total([]) raises ValueError. Which is correct?

3. What is the main advantage of @pytest.mark.parametrize?

4. Two tests pass when run together but one fails when run alone. What is the likely problem?

5. Your suite shows 100% coverage but a rounding bug reaches production. Why?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free