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?

Featurepytestunittest
Assertion syntaxassert x == yself.assertEqual(x, y)
BoilerplateNone — just functionsClasses required
FixturesPowerful, injectablesetUp/tearDown only
ParametrisationBuilt-in decoratorManual loops
Plugin ecosystemHuge (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):

# 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 failed

In 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.01s

Now 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.02s

You 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

By default, fixtures are function-scoped. You can control lifespan with scope:

ScopeRunsUse Case
functionOnce per testSafest, most isolated (default)
classOnce per classRelated tests share instance
moduleOnce per fileExpensive setup (DB connections)
sessionOnce per test runGlobal 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 failed

Stuck 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

SyntaxWhat it does
def test_fn():Define a test (must start with test_)
@pytest.fixtureReusable setup/teardown function
@pytest.mark.parametrizeRun 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