Automated testing · Beginner

Unit tests with pytest that find real bugs

4 min read

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

pricing.py
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.

test_pricing.py
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:

shellsession
$ 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.04s

The 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":

pricing.py
    elif coupon == "FLAT200":
        if subtotal >= Decimal("1000"):
            subtotal -= Decimal("200")
shellsession
$ python3 -m pytest -q
9 passed in 0.01s

The 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 Decimal or 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

  1. Add a rule: SAVE10 cannot be combined with orders over 50,000 rupees. Write the tests first, watch them fail, then implement.

  2. Replace Mock with a small hand-written FakeRates class and compare readability.

Further reading