Unit Testing with pytest
Writing tests that actually catch bugs — fixtures, assertions, mocking, and testing as a habit, not an afterthought.
The Regression a Test Suite Would Have Caught
A discount calculator worked correctly for months. A small, unrelated refactor changes how a nested helper function rounds values, and a subtle off-by-one-cent bug slips into every discount calculation involving an odd number of cents. It ships, unnoticed, until a customer support ticket flags it weeks later. A single test asserting calculate_discount(1001, 10) == 901 would have failed the moment the refactor landed — in CI, before merge, instead of in production, weeks later.
This is the entire case for automated testing in one sentence: a test suite is a fast, repeatable way to verify that code still behaves the way you believe it does, every single time anything changes — not just when it was first written.
Plain assert Statements, No Special Syntax Required
Unlike some testing frameworks that require special assertion methods (self.assertEqual(a, b)), pytest lets you write plain assert statements — the same keyword covered back in the Control Flow module — and produces detailed, readable failure output automatically.
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("cannot divide by zero")
return a / bfrom calculator import add, divide
def test_add():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_divide():
assert divide(10, 2) == 5.0$ pytest
====== test session starts ======
collected 3 items
test_calculator.py ... [100%]
====== 3 passed in 0.02s ======test_*.py or *_test.py, containing functions named test_*, is found and run without any manual registration. This convention-based discovery is a large part of why pytest requires so little boilerplate compared to older testing frameworks.pytest's real advantage — informative failure output
def test_add():
assert add(2, 3) == 6
# ====== FAILURES ======
# ____ test_add ____
#
# def test_add():
# > assert add(2, 3) == 6
# E assert 5 == 6
# E + where 5 = add(2, 3)pytest.raises — Asserting That Something Fails Correctly
import pytest
from calculator import divide
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)
def test_divide_by_zero_message():
with pytest.raises(ValueError, match="cannot divide by zero"):
divide(10, 0)pytest.raises is itself a context manager (from the Context Managers module) — the test passes only if the expected exception is actually raised inside the with block; if no exception is raised at all, the test fails, since that means the code did not behave as expected.
Reusable Setup, Shared Across Tests
A fixture is a function decorated with @pytest.fixture that provides setup (and optional teardown) reusable across multiple tests — pytest automatically detects when a test function's parameter name matches a fixture's name, and calls the fixture to supply that argument.
import pytest
@pytest.fixture
def sample_cart():
return {"items": ["book", "pen"], "total": 25.50}
def test_cart_has_two_items(sample_cart):
assert len(sample_cart["items"]) == 2
def test_cart_total(sample_cart):
assert sample_cart["total"] == 25.50Each test that requests sample_cart gets its own fresh call to the fixture function by default — the two tests above do not share or mutate the same dict, avoiding a common source of test flakiness where one test's leftover mutated state accidentally affects another.
Fixtures with teardown, using yield
@pytest.fixture
def database_connection():
conn = connect_to_test_database()
yield conn # the test runs here, receiving "conn"
conn.close() # runs after the test finishes, pass or fail
def test_insert_record(database_connection):
database_connection.execute("INSERT INTO users VALUES (1, 'Asha')")
assert database_connection.query("SELECT COUNT(*) FROM users") == 1Fixture scope — controlling how often setup runs
@pytest.fixture(scope="function") # default — a fresh instance for EVERY test
def sample_cart():
...
@pytest.fixture(scope="module") # created ONCE, shared across every test in this file
def expensive_database_connection():
...module or session) is a real performance win for expensive setup, but introduces the same shared-mutable-state risk between tests that the default per-test scope avoids. Use a wider scope specifically for read-only or genuinely expensive-to-create resources, and reset any mutable state a wide-scoped fixture exposes between tests if tests actually modify it.One Test Function, Many Inputs
@pytest.mark.parametrize runs the same test function once per set of inputs provided, avoiding repetitive near-identical test functions that only differ in their input values.
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-2, -3) == -5
def test_add_zero():
assert add(0, 5) == 5import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-2, -3, -5),
(0, 5, 5),
])
def test_add(a, b, expected):
assert add(a, b) == expected
# pytest reports each case individually:
# test_add[2-3-5] PASSED
# test_add[-2--3--5] PASSED
# test_add[0-5-5] PASSEDTesting Code That Depends on Something Slow, External, or Unreliable
A function that calls a real API or writes to a real database is hard to test reliably — the test would be slow, could fail due to network issues unrelated to the code being tested, and might have real side effects. unittest.mock (part of the standard library) lets you replace a dependency with a fake stand-in for the duration of a test.
from unittest.mock import patch
def get_weather(city):
response = requests.get(f"https://api.example.com/weather/{city}")
return response.json()["temperature"]
@patch("requests.get")
def test_get_weather(mock_get):
mock_get.return_value.json.return_value = {"temperature": 72}
result = get_weather("Boston")
assert result == 72
mock_get.assert_called_once_with("https://api.example.com/weather/Boston")@patch temporarily replaces requests.get with a fake object for the duration of the test — no real network call ever happens, the test runs in milliseconds, and mock_get.assert_called_once_with(...) lets you additionally verify the function called the dependency correctly, not just that it handled a fake response correctly.
monkeypatch — pytest's built-in alternative for simpler cases
def test_get_weather(monkeypatch):
class FakeResponse:
def json(self):
return {"temperature": 72}
monkeypatch.setattr("requests.get", lambda url: FakeResponse())
assert get_weather("Boston") == 72
# monkeypatch automatically undoes the patch after the test — no manual cleanup neededconftest.py and Test Layout
myproject/
calculator.py
tests/
conftest.py # fixtures shared across MULTIPLE test files
test_calculator.py
test_api.pyconftest.py is a special filename pytest recognises automatically — any fixture defined there is available to every test file in the same directory (and subdirectories) without needing to be explicitly imported, which is exactly where broadly-shared setup (like a test database connection fixture used across many test files) belongs.
Coverage is a signal, not a target to game
pip install pytest-cov
pytest --cov=myprojectA Refactor That Shipped Confidently Because of Tests, at a Raleigh HealthTech Company
A team needs to rewrite the internals of a function that calculates a patient's medication dosage schedule — genuinely high-stakes code, where a bug has real consequences, not just an inconvenience. The function has 40 existing tests covering edge cases accumulated over two years: zero-weight patients, medications with no active ingredient overlap, dosages that round to exactly a boundary value.
@pytest.mark.parametrize("weight_kg, drug, expected_mg", [
(70, "drug_a", 350),
(0.1, "drug_a", 0.5), # a genuinely tiny edge case, added after a real past bug
(150, "drug_b", 600), # the drug_b maximum-dose cap, added after ANOTHER real past bug
])
def test_dosage_calculation(weight_kg, drug, expected_mg):
assert calculate_dosage(weight_kg, drug) == expected_mgWhy the tests, not just careful code review, were what made the refactor safe
The engineer doing the rewrite ran the existing 40 tests continuously while restructuring the internals — every time a change broke one of the historical edge cases (several times during the rewrite), it failed immediately and locally, long before a pull request or a reviewer was even involved. Two of those historical edge-case tests existed specifically because of past real incidents; without them encoded as executable tests, there would have been no way to know the refactor had silently reintroduced either bug until it happened again in production.
Four Misconceptions About Testing
5 Interview Questions — With Complete Answers
Testing Mistakes Beginners Make Constantly
Errors You Will Hit With pytest — And Exactly Why
🎯 Key Takeaways
- ✓pytest discovers tests by naming convention (test_*.py, test_* functions) — no manual registration required, and plain assert statements work with detailed automatic failure output.
- ✓pytest.raises(ExceptionType) verifies that code correctly raises an expected exception, as a context manager.
- ✓Fixtures (@pytest.fixture) provide reusable setup/teardown — yield splits a fixture into setup (before) and teardown (after); scope controls how often it is recreated.
- ✓@pytest.mark.parametrize runs one test function against many input/expected-output pairs, avoiding repetitive near-duplicate tests.
- ✓Mock slow, external, or non-deterministic dependencies (APIs, databases, time) with unittest.mock or pytest's monkeypatch fixture — keep the actual logic under test real.
- ✓conftest.py holds fixtures shared across multiple test files automatically. Coverage measures which lines ran, not whether tests assert anything meaningful — use it to find gaps, not as a target.
What comes next
Module 39 begins the final phase, Production & Career Readiness, with systematic debugging techniques — going beyond print statements to pdb and reading tracebacks like a senior engineer.
Module 39 → Debugging Techniques and ToolsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.