Design behavior-focused tests, isolate external effects, structure a complete application, and automate repeatable quality checks.
Testing is the practice of turning an important expectation into an executable example. A useful test supplies controlled inputs, exercises public behavior, and makes the expected result unambiguous. Tests do not prove that a program has no defects, but they provide fast evidence, prevent known failures from returning, and make change safer.
Learning goals
Write focused tests with arrange, act, and assert phases.
Test results, state changes, and expected exceptions through public interfaces.
Separate pure domain logic from filesystem, clock, network, and presentation boundaries.
Use parametrization, fixtures, temporary paths, and test doubles deliberately.
Organize a modern Python project with declared metadata and repeatable commands.
Use coverage and continuous integration as feedback without confusing them with correctness.
Test observable behavior, not private steps
A test should describe a promise that matters to a caller. It can verify a returned value, a raised exception, a deliberate state change, or an interaction at an external boundary. Tests coupled to local variable names, private helper calls, or an exact internal algorithm often fail during harmless refactoring and discourage improvement.
PYTHON · EDITABLE
definvoice_total(items:list[dict[str,int]])->int:returnsum(item["unit_cents"]* item["quantity"]for item in items)deftest_invoice_total_uses_integer_cents()->None:
items =[{"unit_cents":1_250,"quantity":2},{"unit_cents":499,"quantity":1},]
result = invoice_total(items)assert result ==2_999
The test follows arrange, act, assert: prepare meaningful inputs, perform one behavior, and compare the observable result. Blank lines make those phases visible. A precise test name documents the scenario and expectation when the suite reports a failure.
Discover and run tests with pytest
pytest discovers files commonly named test_*.py and functions named test_*. Install development tools in an isolated environment, run the entire suite from the project root, and use a focused path or keyword while developing. Keep the command that CI runs easy to reproduce locally.
Shell blocks are checked in the browser and never receive access to SovranCode servers or your device.
Read a failing test as a comparison
A failure report identifies the test, the line whose expectation failed, and the difference between actual and expected values. First confirm that the expectation represents the intended contract. Then trace backward to the first incorrect value; do not change production code merely to make a mistaken assertion green.
PYTHON · EDITABLE
definvoice_total(items:list[dict[str,int]])->int:returnsum(item["unit_cents"]* item["quantity"]for item in items)deftest_empty_invoice_is_zero()->None:assert invoice_total([])==0deftest_multiple_quantities_are_multiplied()->None:assert invoice_total([{"unit_cents":750,"quantity":3},])==2_250
test_empty_invoice_is_zero()
test_multiple_quantities_are_multiplied()print("PASS: totals handle empty and repeated items")
Test invalid input and expected exceptions
Failure behavior is part of a public contract. Test the narrow exception type and, when useful, a stable part of its message. If a test catches exceptions manually without failing when none is raised, it can accidentally pass on broken behavior. pytest.raises makes that expectation explicit; the runnable browser example uses the standard library's equivalent assertRaisesRegex so it needs no installed package.
PYTHON · EDITABLE
from dataclasses import dataclass
import unittest
@dataclass(frozen=True)classLineItem:
name:str
unit_cents:int
quantity:int=1def__post_init__(self)->None:if self.quantity <1:raise ValueError("quantity must be at least 1")deftest_negative_quantity_is_rejected()->None:# In pytest: with pytest.raises(ValueError, match="quantity"):with unittest.TestCase().assertRaisesRegex(ValueError,"quantity"):
LineItem("Guide", unit_cents=500, quantity=-1)
test_negative_quantity_is_rejected()print("PASS: invalid quantity is rejected")
Use parametrization for one rule with several cases
Parametrization runs one test body against named input-and-expectation rows. It is ideal when cases express the same rule. Separate tests are clearer when scenarios need different setup, exercise different behavior, or deserve distinct explanations.
A fixture supplies setup through a test parameter and can clean up after the test yields. Prefer small fixtures that represent a stable concept, such as a sample invoice or temporary directory. A large web of automatic fixtures hides inputs and makes failures difficult to understand.
PYTHON · EDITABLE
from dataclasses import dataclass, field
@dataclass(frozen=True)classLineItem:
name:str
unit_cents:int
quantity:int=1@propertydeftotal_cents(self)->int:return self.unit_cents * self.quantity
@dataclassclassInvoice:
reference:str
items:list[LineItem]= field(default_factory=list)defadd(self, item: LineItem)->None:
self.items.append(item)@propertydeftotal_cents(self)->int:returnsum(item.total_cents for item in self.items)# In pytest, decorate this setup function with @pytest.fixture.defsample_invoice()-> Invoice:
invoice = Invoice("INV-001")
invoice.add(LineItem("Notebook",1_250,2))return invoice
deftest_sample_invoice_total(invoice: Invoice)->None:assert invoice.total_cents ==2_500
example = sample_invoice()
test_sample_invoice_total(example)print("PASS: fixture data has the expected total")
Separate the project by responsibility
Keep calculation and validation in a domain module that does not know about terminal input or files. Put conversion and storage in boundary modules, then let a small entry point coordinate them. This dependency direction lets most tests run quickly without touching the operating system.
pyproject.toml records build metadata, supported Python versions, dependencies, optional development tools, and command entry points. Pin exact application dependencies in a lock file when the chosen workflow supports one; use bounded compatibility ranges in reusable libraries. Commit configuration so another developer and CI can recreate the workflow.
Pure functions and data models produce the same result for the same input and avoid hidden effects. Place clocks, randomness, environment variables, databases, files, and network calls at visible boundaries. Pass required behavior inward when a rule depends on it so tests can supply deterministic alternatives.
PYTHON · EDITABLE
from collections.abc import Callable
from datetime import date
defdue_label(due_on: date, today: Callable[[], date])->str:return"overdue"if due_on < today()else"open"deftest_past_invoice_is_overdue()->None:
fixed_today =lambda: date(2026,9,7)assert due_label(date(2026,9,1), fixed_today)=="overdue"
Passing a clock is a small form of dependency injection. It avoids patching global time and makes the dependency visible in the function contract. The same pattern works for ID generators, message senders, and repository interfaces.
Use test doubles at true boundaries
A fake provides a lightweight working implementation, a stub returns controlled data, and a mock records or constrains interactions. Prefer real domain objects and return-value assertions. Verify calls only when the interaction itself is the contract, such as sending one notification through a gateway.
PYTHON · EDITABLE
from dataclasses import dataclass
@dataclass(frozen=True)classInvoice:
reference:str
total_cents:intclassRecordingNotifier:def__init__(self)->None:
self.messages:list[str]=[]defsend(self, message:str)->None:
self.messages.append(message)defannounce_total(invoice: Invoice, notifier: RecordingNotifier)->None:
notifier.send(f"Total: {invoice.total_cents}")deftest_announcement_sends_calculated_total()->None:
notifier = RecordingNotifier()
announce_total(Invoice("INV-001",2_500), notifier)assert notifier.messages ==["Total: 2500"]
test_announcement_sends_calculated_total()print("PASS: one notification records the calculated total")
Test files in isolated temporary directories
pytest's tmp_path fixture provides a unique Path for one test. Write only inside it, pass the path to production code, and assert on durable output. This prevents tests from depending on a developer's files, colliding in parallel, or leaving artifacts behind.
PYTHON · EDITABLE
import json
from pathlib import Path
from tempfile import TemporaryDirectory
defsave_report(path: Path, report:dict)->None:
path.write_text(json.dumps(report, sort_keys=True), encoding="utf-8")deftest_save_report_writes_utf8_json(tmp_path: Path)->None:
destination = tmp_path /"report.json"
save_report(destination,{"reference":"INV-001","total_cents":2500})assert json.loads(destination.read_text(encoding="utf-8"))=={"reference":"INV-001","total_cents":2500,}with TemporaryDirectory()as directory:
test_save_report_writes_utf8_json(Path(directory))print("PASS: report round-trips through an isolated file")
Choose the smallest test that gives useful confidence
Unit tests exercise one focused rule and avoid slow external boundaries.
Integration tests verify that real components cooperate, such as serialization plus filesystem access.
End-to-end tests operate through the application's public entry point and cover a small number of critical journeys.
Regression tests capture a previously observed failure so it cannot silently return.
A healthy suite has many fast focused tests, enough integration tests to verify important seams, and a small number of broader workflow tests. Test labels matter less than choosing a boundary that can fail for a useful reason.
Use coverage to find missing questions
Coverage reports which statements or branches ran; it does not prove that assertions were meaningful. An executed line can still produce the wrong result. Investigate untested error paths and business rules, but do not inflate a percentage with assertions that cannot detect defects.
BASH · EXECUTABLEIsolated browser runner
python -m pytest --cov=invoice_report --cov-report=term-missing
# Useful during development: stop after the first failure
python -m pytest -x# Show the ten slowest tests
python -m pytest --durations=10
Shell blocks are checked in the browser and never receive access to SovranCode servers or your device.
Keep tests deterministic and independent
Control time, randomness, and generated identifiers through explicit dependencies.
Give each test its own mutable objects and temporary resources.
Do not rely on test execution order or state left by another test.
Avoid real network calls in the normal suite; verify adapters separately under controlled conditions.
Assert stable behavior rather than exact whitespace, object representations, or private implementation details unless those are contracts.
Turn every fixed defect into the smallest useful regression test.
Run the same checks in continuous integration
Continuous integration should create a clean environment, install declared dependencies, and run formatting or linting, type checks, and tests on every proposed change. A clean checkout exposes undeclared dependencies and files that only existed on one developer's machine.
Red: write or isolate a small test that fails for the intended reason.
Green: make the smallest production change that satisfies the behavior.
Refactor: improve names and structure while the suite remains green.
Run the focused test while editing, then the complete suite before committing.
If a test fails intermittently, treat the flakiness as a defect. Record the random seed or inputs, remove shared state, control time and concurrency, and identify the real race or dependency instead of automatically retrying until green.
Capstone: complete the invoice report
The runnable notebook below compresses the domain and its tests into one file so it works in the browser. In a real repository, move the models and report function into src/invoice_report/domain.py and the tests into tests/test_domain.py. Run it, deliberately break multiplication, and confirm that the relevant test catches the regression.
Add a percentage discount represented as an integer from 0 through 100.
Reject an empty invoice reference and invalid line items.
Add cases for an empty invoice, multiple quantities, and invalid input.
Keep all money calculations in integer cents.
Create an explicit report dictionary suitable for later JSON serialization.
Explain which future file-writing behavior belongs in a boundary module.
Project definition of done
A new checkout can install the project from its declared metadata.
Domain rules do not import terminal, filesystem, database, or network adapters.
Tests cover normal cases, boundaries, and expected failures.
Tests are deterministic, isolated, and runnable with one documented command.
Lint, type checks, tests, and the critical CLI path pass before release.
The README explains installation, usage, tests, and important design choices.
Course review
You can now structure a Python application around a testable domain, write focused behavior tests, verify errors and boundary integrations, control external dependencies, use project metadata, interpret coverage, and automate checks in CI. More importantly, you can combine the entire course: values, control flow, functions, collections, text, files, exceptions, modules, and classes in one maintainable program.
RUNNABLE PYTHON NOTEBOOK
Build and test a complete invoice report
Run a dependency-free domain model and compact test runner, inspect each passing behavior, then introduce a defect and confirm the suite detects it.
from dataclasses import dataclass, field
from typing import Callable
@dataclass(frozen=True, slots=True)classLineItem:
name:str
unit_cents:int
quantity:int=1def__post_init__(self)->None:ifnot self.name.strip():raise ValueError("name must not be blank")if self.unit_cents <0:raise ValueError("unit_cents must not be negative")if self.quantity <1:raise ValueError("quantity must be at least 1")@propertydeftotal_cents(self)->int:return self.unit_cents * self.quantity
@dataclass(slots=True)classInvoice:
reference:str
items:list[LineItem]= field(default_factory=list)def__post_init__(self)->None:ifnot self.reference.strip():raise ValueError("reference must not be blank")@propertydeftotal_cents(self)->int:returnsum(item.total_cents for item in self.items)defbuild_report(invoice: Invoice)->dict[str,object]:return{"reference": invoice.reference,"item_count":len(invoice.items),"total_cents": invoice.total_cents,}defassert_raises(error_type:type[Exception], action: Callable[[],object])->None:try:
action()except error_type:returnraise AssertionError(f"expected {error_type.__name__}")deftest_empty_invoice()->None:assert build_report(Invoice("INV-EMPTY"))=={"reference":"INV-EMPTY","item_count":0,"total_cents":0}deftest_invoice_with_multiple_quantities()->None:
invoice = Invoice("INV-001",[
LineItem("Notebook",1_250,2),
LineItem("Guide",499),])assert build_report(invoice)["total_cents"]==2_999deftest_invalid_models_are_rejected()->None:
assert_raises(ValueError,lambda: Invoice(" "))
assert_raises(ValueError,lambda: LineItem("Guide",500,0))
tests =[
test_empty_invoice,
test_invoice_with_multiple_quantities,
test_invalid_models_are_rejected,]for test in tests:
test()print(f"PASS: {test.__name__}")print(f"{len(tests)}/{len(tests)} tests passed")
OUTPUT
Press Run code to execute this Python program in your browser.
QUICK CHECK
Test what you learned
Which statement verifies that an expected condition is true in a Python test?
QUICK CHECK
Test what you learned
Which pytest fixture provides an isolated pathlib Path for filesystem tests?
QUICK CHECK
Test what you learned
Which standard project file declares Python metadata, dependencies, and tool configuration?
KNOWLEDGE QUIZ
Check your testing-and-project mental model
Choose an answer for every question, then check the explanations. The goal is not to memorize syntax; it is to predict what Python will do and why.