Testing with pytest
Reviewed & published by Brayan K
Pytest is the standard testing framework for modern Python. Master professional testing strategies used by real engineering teams.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Master
This lesson takes you from basic testing → professional test suite architecture.
✔ Why pytest vs unittest
✔ Fixtures for reusable setup/teardown
✔ Fixture scopes and dependencies
✔ Parametrised tests for multiple inputs
✔ Mocking external systems (APIs, DBs, time)
✔ Professional test suite structure
Part 1: Core pytest — Fixtures, Parametrisation & Basic Mocking
🔥 1. Why pytest?
| Feature | pytest | unittest |
|---|---|---|
| Assertion syntax | assert x == y | self.assertEqual(x, y) |
| Boilerplate | None — just functions | Classes required |
| Fixtures | Powerful, injectable | setUp/tearDown only |
| Parametrisation | Built-in decorator | Manual loops |
| Plugin ecosystem | Huge (1000+ plugins) | Limited |
# test_math_utils.py
def add(a, b):
return a + b
def test_add_two_numbers():
assert add(2, 3) == 5
print("✓ test_add_two_numbers passed!")
# Run the test
test_add_two_numbers()
# ✅ Expected output:
# ✓ test_add_two_numbers passed!🧪 2. Basic Test Structure
Naming rules (by convention):
- Files: test_*.py
- Functions: test_*
- Classes: class TestSomething: (no __init__)
# test_strings.py
def to_upper(text: str) -> str:
return text.upper()
def test_to_upper_basic():
assert to_upper("hello") == "HELLO"
print("✓ test_to_upper_basic passed!")
def test_to_upper_already_upper():
assert to_upper("WORLD") == "WORLD"
print("✓ test_to_upper_already_upper passed!")
# Run tests
test_to_upper_basic()
test_to_upper_already_upper()
# ✅ Expected output:
# ✓ test_to_upper_basic passed!
# ✓ test_to_upper_already_upper passed!🟢 Worked Example: a real test file, end to end
Read this one line by line before you write anything yourself. The top is the code being tested, the middle is four tests for it, and the bottom is a ten-line stand-in for pytest. The browser editor has no pytest installed, so those last lines do the one job pytest normally does for you: find every function whose name starts with test_, call it, and report pass or fail.
# 🟢 WORKED EXAMPLE — a real pytest file, plus a stand-in runner so it works here.
# ---------- the code under test (normally lives in cart.py) ----------
def add_item(cart, name, qty=1):
"""Return a NEW cart dict with qty of 'name' added. Never changes 'cart'."""
updated = dict(cart) # copy first, so the caller's cart is untouched
updated[name] = updated.get(name, 0) + qty # .get(name, 0) -> 0 when the item is new
return updated
def total_pence(cart, prices):
"""Total price in whole pence. Money as integers avoids float rounding bugs."""
return sum(prices[name] * qty for name, qty in cart.items())
# ---------- the tests ----------
# Rule 1: the file is named test_*.py. Rule 2: each test function is named test_*.
# Rule 3: you check things with a plain assert — no special methods, no classes.
# Rule 4: one behaviour per test, and the name says what the behaviour is.
def test_add_item_to_empty_cart():
assert add_item({}, "apple") == {"apple": 1} # qty defaults to 1
def test_add_item_increments_an_existing_line():
assert add_item({"apple": 1}, "apple", 2) == {"apple": 3} # 1 + 2 = 3
def test_add_item_does_not_change_the_original_cart():
original = {"apple": 1}
add_item(original, "pear") # return value deliberately ignored
assert original == {"apple": 1} # proves the dict(cart) copy worked
def test_total_multiplies_price_by_quantity():
prices = {"apple": 50, "pear": 75} # pence, not pounds
assert total_pence({"apple": 2, "pear": 1}, prices) == 175 # 2x50 + 1x75
# ---------- stand-in runner: this is the bit pytest replaces ----------
test_names = sorted(n for n in list(globals()) if n.startswith("test_"))
passed = failed = 0
for name in test_names:
try:
globals()[name]() # pytest calls each test with no arguments
print(f"PASS {name}")
passed += 1
except AssertionError as err: # a failed assert raises AssertionError
print(f"FAIL {name} -> {err}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
# ✅ Expected output:
# PASS test_add_item_does_not_change_the_original_cart
# PASS test_add_item_increments_an_existing_line
# PASS test_add_item_to_empty_cart
# PASS test_total_multiplies_price_by_quantity
#
# 4 passed, 0 failedIn a real project you throw the runner away, save the tests as test_cart.py next to cart.py, and type pytest -q. That prints one dot per passing test:
.... [100%]
4 passed in 0.01sNow the payoff, and the real reason people use pytest. Break total_pence so it forgets to multiply by the quantity, and pytest rewrites your plain assert into a report that shows you the actual numbers:
...F [100%]
=================================== FAILURES ===================================
___________________ test_total_multiplies_price_by_quantity ____________________
def test_total_multiplies_price_by_quantity():
prices = {"apple": 50, "pear": 75}
> assert total_pence({"apple": 2, "pear": 1}, prices) == 175
E AssertionError: assert 125 == 175
E + where 125 = total_pence({'apple': 2, 'pear': 1}, {'apple': 50, 'pear': 75})
test_cart.py:20: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_total_multiplies_price_by_quantity - AssertionError...
1 failed, 3 passed in 0.02sYou never asked for that detail — you only wrote assert ... == 175. pytest inspected the expression and told you it got 125, and exactly which call produced it. (The test_cart.py:20 line points at wherever that assert sits in your own file, so your number may differ.)
⚙️ 3. What Is a Fixture?
A fixture is reusable setup logic that you can "inject" into tests via function arguments.
# Simulating pytest fixtures
def sample_user():
"""Fixture that provides a sample user"""
return {"id": 1, "name": "Brayan", "role": "admin"}
def test_user_has_name():
user = sample_user()
assert user["name"] == "Brayan"
print("✓ test_user_has_name passed!")
def test_user_is_admin():
user = sample_user()
assert user["role"] == "admin"
print("✓ test_user_is_admin passed!")
test_user_has_name()
test_user_is_admin()
# ✅ Expected output:
# ✓ test_user_has_name passed!
# ✓ test_user_is_admin passed!🧱 4. Fixture Scopes
- • function = Clean after every guest (fresh for each test)
- • class = Clean after a group of guests checks out
- • module = Clean once a day (per file)
- • session = Deep clean once a week (per test run)
By default, fixtures are function-scoped. You can control lifespan with scope:
| Scope | Runs | Use Case |
|---|---|---|
| function | Once per test | Safest, most isolated (default) |
| class | Once per class | Related tests share instance |
| module | Once per file | Expensive setup (DB connections) |
| session | Once per test run | Global resources (app config) |
print("Fixture Scopes in pytest:")
print("=" * 40)
print("""
@pytest.fixture(scope="function") # default - fresh per test
def db_connection():
...
@pytest.fixture(scope="class") # once per test class
def api_client():
...
@pytest.fixture(scope="module") # once per file
def config():
...
@pytest.fixture(scope="session") # once per entire test run
def app_env():
...
""")
print("When to use which?")
print("-" * 30)
print("function → safest; isolated; most common")
print("class → all tests in a class share one instance")
print("module → all tests in a file share one instance")
print("session → one instance for entire test run")
# ✅ Expected output:
# Fixture Scopes in pytest:
# ========================================
#
# @pytest.fixture(scope="function") # default - fresh per test
# def db_connection():
# ...
#
# @pytest.fixture(scope="class") # once per test class
# def api_client():
# ...
#
# @pytest.fixture(scope="module") # once per file
# def config():
# ...
#
# @pytest.fixture(scope="session") # once per entire test run
# def app_env():
# ...
#
# When to use which?
# ------------------------------
# function → safest; isolated; most common
# class → all tests in a class share one instance
# module → all tests in a file share one instance
# session → one instance for entire test run🔁 5. Setup & Teardown with yield
import tempfile
import os
def temp_file_fixture():
"""Fixture with setup and teardown"""
# Setup
fd, path = tempfile.mkstemp(suffix=".txt")
os.write(fd, b"initial content")
os.close(fd)
print(f"Setup: Created temp file {path}")
# Return value to test
yield path
# Teardown
os.unlink(path)
print(f"Teardown: Deleted temp file")
def test_temp_file():
# Use generator to simulate fixture
gen = temp_file_fixture()
file_path = next(gen)
# Test logic
with open(file_path, 'r') as f:
content = f.read()
assert content == "initial content"
print("✓ Test passed!")
# Trigger teardown
try:
next(gen)
except StopIteration:
pass
test_temp_file()🧪 6. Parametrised Tests
Instead of writing multiple duplicate tests, use parametrisation:
# Simulating @pytest.mark.parametrize
def add(a, b):
return a + b
test_cases = [
(1, 2, 3),
(10, 5, 15),
(-1, 1, 0),
(0, 0, 0),
]
def test_add_parametrized():
for a, b, expected in test_cases:
result = add(a, b)
assert result == expected, f"Failed: add({a}, {b}) = {result}, expected {expected}"
print(f"✓ add({a}, {b}) = {expected}")
test_add_parametrized()
print("\nAll parametrised tests passed!")
# ✅ Expected output:
# ✓ add(1, 2) = 3
# ✓ add(10, 5) = 15
# ✓ add(-1, 1) = 0
# ✓ add(0, 0) = 0
#
# All parametrised tests passed!🎯 Your Turn: write the assertions
Everything below is written for you except the expected values — the part only a human can decide. Work out each number in your head first, then fill in the blanks and run it. If your last line does not read 3 passed, 0 failed, read the FAIL line: it tells you which test disagreed with you.
# 🎯 YOUR TURN — fill in the blanks marked with ___
def discounted_pence(price_pence, percent_off):
"""Price after a percentage discount, rounded to the nearest whole penny."""
return round(price_pence * (100 - percent_off) / 100)
# 1) 10% off 100p. Work it out, then write the number.
def test_ten_percent_off_one_pound():
assert discounted_pence(100, 10) == ___ # 👉 replace ___ with the pence you expect
# 2) A 0% discount must leave the price completely alone.
def test_zero_percent_leaves_price_alone():
assert discounted_pence(250, 0) == ___ # 👉 replace ___ with the pence you expect
# 3) A table-driven test: one row per case, one loop to check them all.
# This is exactly what @pytest.mark.parametrize does for you in a real suite.
cases = [
(100, 50, 50), # 50% off 100p -> 50p
(999, 100, 0), # 100% off anything -> 0p
(___, ___, ___), # 👉 invent a third case: price, percent off, expected pence
]
def test_discount_table():
for price, percent, expected in cases:
# The message after the comma only prints when the assert fails —
# without it you would not know WHICH row broke.
assert discounted_pence(price, percent) == expected, f"{price} at {percent}% off"
# ---- stand-in runner (same ten lines as the worked example) ----
test_names = sorted(n for n in list(globals()) if n.startswith("test_"))
passed = failed = 0
for name in test_names:
try:
globals()[name]()
print(f"PASS {name}")
passed += 1
except AssertionError as err:
print(f"FAIL {name} -> {err}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
# ✅ Expected output once all three blanks are right:
# PASS test_discount_table
# PASS test_ten_percent_off_one_pound
# PASS test_zero_percent_leaves_price_alone
#
# 3 passed, 0 failedStuck on the third row? (200, 25, 150) works — a quarter off 200p leaves 150p.
🧪 7. Basic Mocking
from unittest.mock import Mock
def fetch_user(api):
"""Function that uses an API"""
return api.get("/user")
def test_fetch_user():
# Create a mock API
fake_api = Mock()
fake_api.get.return_value = {"id": 1, "name": "Boopie"}
# Call function with mock
result = fetch_user(fake_api)
# Assertions
assert result["name"] == "Boopie"
fake_api.get.assert_called_once_with("/user")
print("✓ test_fetch_user passed!")
test_fetch_user()
# ✅ Expected output:
# ✓ test_fetch_user passed!Part 2: Advanced Fixtures & Mocking
🔧 Factory Fixtures
def make_user(name, role="viewer"):
"""Factory function for creating users"""
return {"name": name, "role": role}
def test_user_factory():
u1 = make_user("Alice")
u2 = make_user("Bob", role="admin")
assert u1["role"] == "viewer"
assert u2["role"] == "admin"
print(f"✓ Created users: {u1}, {u2}")
test_user_factory()
# ✅ Expected output:
# ✓ Created users: {'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'admin'}🧠 Combining Fixtures + Parametrisation
# Simulating combined fixtures and parametrisation
numbers = [1, 2, 3]
factors = [10, 20]
def test_multiplied():
results = []
for number in numbers:
for factor in factors:
result = number * factor
results.append((number, factor, result))
print(f"✓ {number} × {factor} = {result}")
print(f"\nTotal combinations: {len(results)}")
test_multiplied()
# ✅ Expected output:
# ✓ 1 × 10 = 10
# ✓ 1 × 20 = 20
# ✓ 2 × 10 = 20
# ✓ 2 × 20 = 40
# ✓ 3 × 10 = 30
# ✓ 3 × 20 = 60
#
# Total combinations: 6🔐 Monkeypatching
# Original function
def get_price():
return 999 # Imagine this calls an API
# Test with monkeypatch simulation
original_get_price = get_price
def test_price():
global get_price
# Monkeypatch
get_price = lambda: 10
assert get_price() == 10
print("✓ Monkeypatched get_price() returns 10")
# Restore
get_price = original_get_price
test_price()
print(f"Original get_price(): {get_price()}")
# ✅ Expected output:
# ✓ Monkeypatched get_price() returns 10
# Original get_price(): 999🛰 Mocking External APIs
from unittest.mock import Mock
def fetch_user_data(client):
response = client.get("https://api.example.com/user")
return response.json()
def test_fetch_user_with_mock():
# Create mock client
mock_client = Mock()
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Boopie"}
mock_client.get.return_value = mock_response
# Test
result = fetch_user_data(mock_client)
assert result["name"] == "Boopie"
mock_client.get.assert_called_once()
print("✓ API mock test passed!")
test_fetch_user_with_mock()
# ✅ Expected output:
# ✓ API mock test passed!🧪 Spy Objects
from unittest.mock import Mock
def test_call_tracking():
# Create a mock that tracks calls
func = Mock()
# Make some calls
func("hello")
func("world", count=5)
# Verify calls
func.assert_called()
assert func.call_count == 2
# Check specific calls
func.assert_any_call("hello")
func.assert_any_call("world", count=5)
print(f"✓ Function called {func.call_count} times")
print(f"✓ Call history: {func.call_args_list}")
test_call_tracking()
# ✅ Expected output:
# ✓ Function called 2 times
# ✓ Call history: [call('hello'), call('world', count=5)]Part 3: Professional Test Suite Architecture
🧪 Test Suite Structure
print("""
Professional Test Suite Structure:
===================================
project/
app/
core/
api/
models/
tests/
unit/ → pure logic, fast tests
integration/ → DB, API, filesystem
e2e/ → browser/full system
fixtures/ → large reusable mocks
conftest.py → global fixtures
Key principles:
---------------
✓ Separate unit, integration, and e2e tests
✓ Use conftest.py for shared fixtures
✓ Keep tests fast and isolated
✓ Mock external dependencies
✓ Use parametrisation for coverage
""")
# ✅ Expected output:
#
# Professional Test Suite Structure:
# ===================================
#
# project/
# app/
# core/
# api/
# models/
# tests/
# unit/ → pure logic, fast tests
# integration/ → DB, API, filesystem
# e2e/ → browser/full system
# fixtures/ → large reusable mocks
# conftest.py → global fixtures
#
# Key principles:
# ---------------
# ✓ Separate unit, integration, and e2e tests
# ✓ Use conftest.py for shared fixtures
# ✓ Keep tests fast and isolated
# ✓ Mock external dependencies
# ✓ Use parametrisation for coverage🧱 Integration Testing
import sqlite3
def test_database_integration():
# Setup: Create in-memory database
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
# Create table
cur.execute("CREATE TABLE users (id INT, name TEXT)")
# Insert data
cur.execute("INSERT INTO users VALUES (1, 'Alice')")
conn.commit()
# Query and verify
result = cur.execute("SELECT name FROM users WHERE id=1").fetchone()
assert result[0] == "Alice"
# Cleanup
conn.close()
print("✓ Database integration test passed!")
test_database_integration()
# ✅ Expected output:
# ✓ Database integration test passed!🧱 Mocking Time and Randomness
import time
import random
# Save originals
original_time = time.time
original_randint = random.randint
def test_mocked_time():
# Mock time
time.time = lambda: 1000.0
assert time.time() == 1000.0
print(f"✓ Mocked time: {time.time()}")
# Restore
time.time = original_time
def test_mocked_random():
# Mock random
random.randint = lambda a, b: 42
assert random.randint(1, 100) == 42
print(f"✓ Mocked random: {random.randint(1, 100)}")
# Restore
random.randint = original_randint
test_mocked_time()
test_mocked_random()
print(f"\nReal time: {original_time():.0f}")🏁 Mini-Challenge: test it yourself (no scaffolding)
This time the tests are yours. The function is written and working; your job is to pin its behaviour down so nobody can break it later without the suite shouting.
initials("ada lovelace") returns "A.L.". Write three tests: a normal two-word name, a single-word name, and a name padded with extra spaces. Name each one test_ + what it checks, and use one assert per test.
# 🏁 MINI-CHALLENGE — write the tests yourself
def initials(full_name):
"""'ada lovelace' -> 'A.L.' (split on spaces, first letter of each part, upper-cased)"""
return "".join(part[0].upper() + "." for part in full_name.split())
# 1. def test_two_word_name(): assert initials("ada lovelace") == ...
# 2. def test_single_word_name(): what should initials("prince") give?
# 3. def test_extra_spaces_are_ignored(): try " grace hopper "
#
# Rules: names start with test_, one assert each, no arguments.
# your tests here
# ---- stand-in runner: leave this at the bottom, it finds whatever you wrote ----
test_names = sorted(n for n in list(globals()) if n.startswith("test_"))
passed = failed = 0
for name in test_names:
try:
globals()[name]()
print(f"PASS {name}")
passed += 1
except AssertionError as err:
print(f"FAIL {name} -> {err}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
# ✅ When your three tests are written and passing, the last line reads:
# 3 passed, 0 failed
# (Before you write anything it says "0 passed, 0 failed" — a suite that finds
# no tests is always "green", which is why an empty test file is dangerous.)🎓 Summary
You've learned professional pytest strategies:
✅ Fixtures for reusable setup/teardown
✅ Fixture scopes and dependencies
✅ Parametrised tests for multiple inputs
✅ Professional test suite structure
📋 Quick Reference — pytest
| Syntax | What it does |
|---|---|
| def test_fn(): | Define a test (must start with test_) |
| @pytest.fixture | Reusable setup/teardown function |
| @pytest.mark.parametrize | Run test with multiple inputs |
| pytest.raises(ValueError) | Assert an exception is raised |
| mocker.patch('module.fn') | Mock a function with pytest-mock |
🎉 Great work! You've completed this lesson.
You can now write fixtures, parametrised tests, and mocks — the full professional pytest toolkit used at tech companies worldwide.
Practice quiz
How do pytest tests check conditions?
- self.assertEqual(x, y)
- A plain assert statement, e.g. assert add(2, 3) == 5
- expect(x).toBe(y)
- check(x == y)
Answer: A plain assert statement, e.g. assert add(2, 3) == 5. pytest uses Python's built-in assert — no special methods or boilerplate classes required.
By naming convention, pytest discovers test functions that...
- start with test_
- end with _test
- are inside a Test class only
- have a @test decorator
Answer: start with test_. Files are test_*.py and functions start with test_ — that's how pytest finds them.
What is a pytest fixture?
- A failed test
- Reusable setup logic injected into tests via function arguments
- A mocking library
- A configuration file
Answer: Reusable setup logic injected into tests via function arguments. Fixtures provide reusable setup/teardown that tests receive as arguments.
What is the DEFAULT fixture scope?
- session
- module
- class
- function
Answer: function. Fixtures are function-scoped by default — fresh for each test, the safest and most isolated option.
Which scope runs a fixture only ONCE per entire test run?
- function
- class
- module
- session
Answer: session. session scope creates one instance for the whole run — ideal for global resources like app config.
In a fixture, what does the code AFTER a yield statement do?
- Provides the value to the test
- Runs as teardown after the test finishes
- Skips the test
- Nothing
Answer: Runs as teardown after the test finishes. yield returns the value; the lines after yield run as teardown once the test completes.
Why use @pytest.mark.parametrize?
- To mock external systems
- To run one test function with many input/output cases
- To set fixture scope
- To skip slow tests
Answer: To run one test function with many input/output cases. Parametrisation runs the same test across multiple inputs instead of duplicating test functions.
After fake_api.get.return_value = {'name': 'Boopie'}, what does fetch_user(fake_api) return for result['name']?
- None
- Boopie
- An error
- 'name'
Answer: Boopie. A Mock's return_value is what the call yields, so api.get('/user') returns that dict.
What does fake_api.get.assert_called_once_with('/user') verify?
- That get returned '/user'
- That get was called exactly once with that argument
- That get raised an exception
- That get is a real API
Answer: That get was called exactly once with that argument. It asserts the mock was called exactly one time with the given argument — a key way to verify behavior.
Which pytest construct asserts that a block raises a specific exception?
- pytest.raises(ValueError)
- pytest.expect(ValueError)
- assert ValueError
- pytest.catch(ValueError)
Answer: pytest.raises(ValueError). with pytest.raises(ValueError): ... passes only if that exception is raised inside the block.
Continue this course
- Previous: Logging, Debugging & Error Handling at Scale
- Next: Building Command-Line Tools with argparse & Typer — Build polished CLI tools with argument parsing and subcommands
- Quick reference: Python cheat sheet