BeginnerTesting and quality~35 minPython

Testing and Quality

Write pytest tests using Arrange-Act-Assert, test that code raises the right exception, think in boundary cases, and keep tests deterministic.

By the end of this lesson, you can

  • Write a pytest test function using the Arrange-Act-Assert structure
  • Test that a function raises a specific exception with pytest.raises
  • Identify boundary cases worth testing beyond the "obvious" input
  • Explain what makes a test deterministic, and what commonly breaks that

Why it matters

Every lesson so far has ended with a tested, verified example — the site’s own build fails loudly if any of them stops producing the output the lesson claims it does. That’s not a special technique reserved for building a learning site; it’s the same discipline this lesson teaches directly, and it closes out the Beginner tier by turning “I think this works” into something actually checked.

Mental model

A pytest test is a function whose name starts with test_, containing one or more assert statements. The Arrange-Act-Assert shape organizes its body: set up inputs, call the one thing being tested, then check the result.

Boundary-case thinking means testing more than the obviously-correct input: zero, negative numbers, an empty collection, the exact edge of a valid range — the values most likely to expose a mistake, precisely because they’re easy to forget while writing the code in the first place.

A deterministic test produces the same result every time it runs, on any machine, in any order relative to other tests — which rules out depending on the real current time, unseeded randomness, network access, or leftover state from a previous test.

Worked example: testing a small conversion module

Pythontemperature.py
def celsius_to_fahrenheit(celsius: float) -> float:
    return celsius * 9 / 5 + 32


def fahrenheit_to_celsius(fahrenheit: float) -> float:
    return (fahrenheit - 32) * 5 / 9
Pythontest_temperature.py
import pytest
from temperature import celsius_to_fahrenheit, fahrenheit_to_celsius


def test_celsius_to_fahrenheit_freezing():
    # Arrange
    celsius = 0

    # Act
    result = celsius_to_fahrenheit(celsius)

    # Assert
    assert result == 32


def test_celsius_to_fahrenheit_boiling():
    assert celsius_to_fahrenheit(100) == 212


def test_fahrenheit_to_celsius_roundtrip():
    original = 37.0
    fahrenheit = celsius_to_fahrenheit(original)
    assert fahrenheit_to_celsius(fahrenheit) == pytest.approx(original)


def test_agreement_point():
    # -40 is the one temperature where both scales read the same number —
    # a genuine boundary case, not an arbitrary extra test.
    assert celsius_to_fahrenheit(-40) == -40
Outputpytest test_temperature.py -v
test_temperature.py::test_celsius_to_fahrenheit_freezing PASSED
test_temperature.py::test_celsius_to_fahrenheit_boiling PASSED
test_temperature.py::test_fahrenheit_to_celsius_roundtrip PASSED
test_temperature.py::test_agreement_point PASSED

4 passed

test_celsius_to_fahrenheit_freezing spells out Arrange/Act/Assert explicitly; the other three compress the same shape onto one line each, once it’s familiar. test_fahrenheit_to_celsius_roundtrip uses pytest.approx(...) instead of == because the round-trip involves floating-point division — the same representation-limit issue Strings and Text touched on for 0.1 + 0.2; comparing floats for exact equality after arithmetic is unreliable even when the math is conceptually correct. test_agreement_point is boundary-case thinking made concrete: -40°C and -40°F are the same temperature — the one point where the two conversion functions’ outputs coincide — which is exactly the kind of value worth checking deliberately rather than by accident.

Common mistakes

  • Testing only the “obviously correct” case (celsius_to_fahrenheit(20)) and skipping boundary values (zero, negative, the agreement point) — bugs cluster at boundaries far more than in the comfortable middle of a valid range.
  • Comparing floats with == after arithmetic instead of pytest.approx(...) — a test that fails intermittently due to floating-point representation, not an actual bug, erodes trust in the test suite.
  • Writing a test that depends on the real current time, unseeded randomness, or a live network call — any of these can make a correct test fail unpredictably, becoming a flaky test.

Check your understanding

def divide(a, b): return a / b def test_divide_by_zero(): result = divide(10, 0) assert result == 0 What happens when this test runs?
Or reveal the answer without checking

Answer:It fails with a ZeroDivisionError, never reaching the assert line at all
10 / 0 raises ZeroDivisionError immediately — the assert on the next line never executes, and the exception propagates up as a test failure. This is exactly the scenario pytest.raises exists for: testing that an exception happens is a different assertion than testing a returned value.

A test calls a function and expects it to raise ValueError. Which is the correct way to test that?
Or reveal the answer without checking

Answer:with pytest.raises(ValueError): function_call()
pytest.raises(ExceptionType) is a context manager built specifically to assert that the code inside it raises that exception — the test fails if nothing is raised, or if a different exception type is raised instead.

Practice: warm-up

Write a test using pytest.raises for a parse_positive_int function (from Errors and Debugging) that should raise ValueError for the input "-5". Predict, before checking, whether a bare assert parse_positive_int("-5") (no pytest.raises) would correctly test this case.

Stuck? Reveal one hint at a time.

  1. Hint 1

    parse_positive_int("-5") raises ValueError before it ever returns anything at all — think about what happens to code on the line after a call that raises.

  2. Hint 2

    assert needs a value to check truthiness on. If the call it depends on raises before producing a value, the assert statement itself never runs.

  3. Hint 3

    No — a bare assert parse_positive_int("-5") would itself raise ValueError (uncaught), which does make the test fail, but for the wrong stated reason and without checking it's specifically ValueError rather than any other exception. pytest.raises(ValueError): parse_positive_int("-5") is the correct, explicit way to test this.

Reveal the correct test

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

import pytest


def test_parse_positive_int_rejects_negative():
    with pytest.raises(ValueError):
        parse_positive_int("-5")

An un-wrapped assert parse_positive_int("-5") would technically also fail the test (the raised exception propagates and pytest reports a failure) — but it fails as an error, not as a correctly-asserted expectation, and it wouldn’t distinguish ValueError from some other, wrong exception type the function might raise instead.

Practice: apply it

Add tests to an earlier lesson’s project (the B1 project’s expense summarizer is a good candidate): at least one boundary case (an empty input list), one pytest.raises test for invalid input, and one normal-case test. Predict which of the three is most likely to reveal a bug that the “obvious” happy-path test alone would have missed.

Which is most likely to reveal a bug the happy-path test alone would miss?
Or reveal the answer without checking

Answer:The empty-input boundary case — code that loops over or reduces a collection often assumes at least one item exists
Code that sums, averages, or otherwise reduces a collection frequently has a hidden assumption that the collection is non-empty (dividing by len(items), for instance) — the empty-input case is a classic boundary that the happy path alone never exercises.

Summary

  • A pytest test is a test_-prefixed function containing assert statements, structured as Arrange-Act-Assert: set up, act, then check.
  • pytest.raises(ExceptionType) tests that code raises a specific exception — a plain assert can’t express this, since raising skips the rest of the function, including any assertion after the call.
  • Boundary cases (zero, negative, empty, the exact edge of a range) are disproportionately likely to reveal bugs — test them deliberately, not just the comfortable middle case.
  • A deterministic test never depends on real time, unseeded randomness, network access, or another test’s leftover state — anything that does risks becoming a flaky test.

Beginner tier complete

This closes out all eleven Beginner modules — B1 through B11. From here, the Beginner projects combine everything they taught into three portfolio-quality builds.