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
def celsius_to_fahrenheit(celsius: float) -> float:
return celsius * 9 / 5 + 32
def fahrenheit_to_celsius(fahrenheit: float) -> float:
return (fahrenheit - 32) * 5 / 9import 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) == -40test_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 passedtest_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 ofpytest.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
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.
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.
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.
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.
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.
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 containingassertstatements, structured as Arrange-Act-Assert: set up, act, then check. pytest.raises(ExceptionType)tests that code raises a specific exception — a plainassertcan’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.