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
mkdir brew_api
cd brew_api
python -m venv .venv
# Windows PowerShell: .venv\Scripts\Activate.ps1
# macOS / Linux: source .venv/bin/activate
pip install pytestCreates a project folder with its own virtual environment and installs pytest.
Put pricing.py in this folder. This is the code under test:
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:
from pricing import calculate_total
def test_single_item():
assert calculate_total([{"item_id": "espresso", "qty": 1}]) == 120A test is a function whose name starts with test_. It passes if no assertion fails.
pytestRun from the brew_api folder.
============================ 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:
def test_several_items_wrong_on_purpose():
items = [{"item_id": "espresso", "qty": 2}, {"item_id": "croissant", "qty": 1}]
assert calculate_total(items) == 360The correct total is 2 x 120 + 150 = 390, so this should fail.
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 == 360pytest 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:
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.
@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}]) == expectedThe first test runs 3 times (0, 11, -1); the second runs twice. Together they cover both edges and both sides of each edge.
pytest -v-v prints one line per test, including the parameters.
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] PASSEDWhen 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.
@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) == 310pytest 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
@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 validEquivalence 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.
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 validcollected 16 items
test_pricing.py ................ [100%]
============================== 16 passed in 0.28s ==============================The everyday test loop
- 1Pick one behaviour, such as 'quantity 11 is rejected'.
- 2Write the test first and run
pytest. It should fail, which proves the test can fail. - 3Change the code as little as needed to make it pass, and run again.
- 4Add the next case (a boundary or a negative input) and repeat.
- 5Finish by running the whole suite and reading any failure message carefully.
Useful pytest commands
Everyday commands
| Command | What it does |
|---|---|
| pytest | Run all tests in the current folder and below |
| pytest -v | One line per test |
| pytest test_pricing.py | Run one file |
| pytest test_pricing.py::test_phone | Run one test function |
| pytest -k phone | Run tests whose name contains 'phone' |
| pytest -x | Stop at the first failure |
| pytest --lf | Re-run only the tests that failed last time |
| pytest -q | Quieter output |
Qualities of a good unit test
- It tests one behaviour, and its name says which.
- It is independent: it passes alone and in any order.
- It is fast and uses no network or real database.
- It has a clear arrange, act, assert shape: set up data, call the code, check the result.
- It would fail if the code were wrong. (Try breaking the code to check.)
- 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.
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
- A unit test calls one small piece of code and asserts the result; pytest finds files named
test_*.pyand functions namedtest_*. - Plain
assertis enough. pytest explains failures clearly: read the expected and actual values. - Use
pytest.raises(ErrorType, match=...)to test that bad input is rejected. @pytest.mark.parametrizeruns one test with many inputs. Use it for boundaries and equivalence classes.- Fixtures prepare fresh shared data for tests that ask for them by name.
- 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.