Automated testing · Beginner
Unit tests with pytest that find real bugs
Why it matters
Testing appeared in a third of the Software Engineer job descriptions we read, and code without tests rarely passes review. Good unit tests are fast, focused on behaviour and written for the cases that are easy to get wrong: boundaries, invalid input and combinations of rules.
The concept
The test pyramid. Many fast unit tests of logic, fewer integration tests of components working together (a real database, a real HTTP layer), and a few end-to-end tests of whole user journeys.
Arrange, act, assert. Set up the input, call the code, check the result. One behaviour per test, and a name that says what the behaviour is.
Fixtures (
@pytest.fixture) build shared setup. Parametrise (@pytest.mark.parametrize) runs one test over many cases.Test doubles. Replace slow or unpredictable dependencies (network, clock, randomness) with a stub or mock so the test is fast and repeatable.
Mock(spec=...)fails if you call a method the real class does not have.Test edges. Zero, negative, just below and just above thresholds, unknown values, empty lists.
Worked example: order pricing
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
@dataclass(frozen=True)
class Line:
sku: str
qty: int
unit_price: Decimal
def order_total(lines: list[Line], coupon: str | None = None, gst_rate: Decimal = Decimal("0.18")) -> Decimal:
"""Subtotal, then coupon, then GST, rounded to the paisa."""
if any(line.qty <= 0 for line in lines):
raise ValueError("quantity must be positive")
subtotal = sum((line.unit_price * line.qty for line in lines), Decimal("0"))
if coupon == "SAVE10":
subtotal *= Decimal("0.90")
elif coupon == "FLAT200" and subtotal >= Decimal("1000"):
subtotal -= Decimal("200")
elif coupon is not None:
raise ValueError(f"unknown coupon {coupon}")
total = subtotal * (1 + gst_rate)
return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
class ExchangeRates:
"""Wraps a slow external API. Tests replace it."""
def rate(self, currency: str) -> Decimal:
raise RuntimeError("network call not allowed in unit tests")
def total_in(currency: str, lines: list[Line], rates: ExchangeRates) -> Decimal:
inr = order_total(lines)
return (inr / rates.rate(currency)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)The rule for FLAT200 is "200 rupees off orders of 1,000 or more". Below 1,000 the coupon should simply not apply.
from decimal import Decimal
from unittest.mock import Mock
import pytest
from pricing import ExchangeRates, Line, order_total, total_in
@pytest.fixture
def cart():
return [Line("SKU-1", 2, Decimal("499.00")), Line("SKU-2", 1, Decimal("250.50"))]
def test_total_includes_gst(cart):
# (2 x 499.00 + 250.50) x 1.18 = 1473.23
assert order_total(cart) == Decimal("1473.23")
@pytest.mark.parametrize(
"coupon, expected",
[
(None, Decimal("1473.23")),
("SAVE10", Decimal("1325.91")),
("FLAT200", Decimal("1237.23")),
],
)
def test_coupons(cart, coupon, expected):
assert order_total(cart, coupon) == expected
def test_flat_coupon_needs_minimum_spend():
small = [Line("SKU-3", 1, Decimal("999.99"))]
assert order_total(small, "FLAT200") == order_total(small)
@pytest.mark.parametrize("bad", [0, -1])
def test_rejects_non_positive_quantity(bad):
with pytest.raises(ValueError, match="quantity"):
order_total([Line("SKU-1", bad, Decimal("10"))])
def test_unknown_coupon_is_an_error(cart):
with pytest.raises(ValueError, match="unknown coupon"):
order_total(cart, "FREE")
def test_currency_conversion_uses_rate_service(cart):
rates = Mock(spec=ExchangeRates)
rates.rate.return_value = Decimal("83.50")
assert total_in("USD", cart, rates) == Decimal("17.64")
rates.rate.assert_called_once_with("USD")Running it:
$ python3 -m pytest -q
...
> raise ValueError(f"unknown coupon {coupon}")
E ValueError: unknown coupon FLAT200
FAILED test_pricing.py::test_flat_coupon_needs_minimum_spend - ValueError: un...
1 failed, 8 passed in 0.04sThe boundary test found a real bug. When the subtotal is below 1,000, the FLAT200 branch is skipped and the code falls through to "unknown coupon". The fix separates "which coupon" from "does it apply":
elif coupon == "FLAT200":
if subtotal >= Decimal("1000"):
subtotal -= Decimal("200")$ python3 -m pytest -q
9 passed in 0.01sThe exchange-rate test never touches the network: the mock returns a fixed rate and the test also checks the rate service was asked for the right currency.
Common mistakes
Testing only the happy path. The bug above only appears just below a threshold.
Tests that repeat the implementation. Assert on behaviour (the total) rather than on internal steps.
Mocking everything. Mock slow or external boundaries, not your own simple functions; over-mocked tests pass while the real code is broken.
Floats for money in tests and code. Use
Decimalor integer paise so expected values are exact.
Interview angle
"How would you test this function?" List the cases before writing code: normal, boundaries, invalid input, combinations. "What is the difference between a mock and a stub?" (a stub returns canned answers; a mock also records and checks how it was called). "What is TDD?" (write a failing test first, make it pass, then refactor).
Practice task
Add a rule: SAVE10 cannot be combined with orders over 50,000 rupees. Write the tests first, watch them fail, then implement.
Replace
Mockwith a small hand-writtenFakeRatesclass and compare readability.