Pytest and modern quality tools
Move from a single test file to a repeatable workflow with fixtures, parametrization, coverage, linting, and type checks.
By the end, you’ll be able to…
- Write pytest tests with plain assertions and discover them by name.
- Use fixtures and temporary paths to isolate setup and cleanup.
- Run tests, coverage, linting, and type checks in a repeatable sequence.
01Start with a plain function and a plain assert
pytest discovers files named test_*.py or *_test.py and functions named test_*. A test is an ordinary function that calls the code and asserts the result. pytest rewrites assertions to show the values involved, so a failed equality check usually tells you what happened without adding a custom message.
Keep one behavior per test and give the test a name that states the contract. A test that checks both valid and invalid input in one function can stop at the first failure and hide the second problem. pytest does not require a class or a special assertion method, although it can run unittest-style tests too.
import pytestdef parse_port(text): value = int(text) if not 1 <= value <= 65535: raise ValueError("port must be between 1 and 65535") return valuedef test_accepts_a_valid_port(): assert parse_port("8080") == 8080def test_rejects_a_port_above_the_range(): with pytest.raises(ValueError, match="between"): parse_port("70000")02Fixtures make setup repeatable
A fixture is a function that prepares a resource for tests. pytest injects the fixture by matching the test parameter name to the fixture name. The built-in tmp_path fixture gives each test a fresh temporary directory, so tests do not depend on files left by another run. A yield fixture runs its cleanup after the test finishes.
Fixtures can be small helpers or larger application boundaries. Use them when setup is repeated and the same contract needs to hold for every test. Avoid hiding important setup in a large fixture that makes a test impossible to read; the test should still make clear what it depends on.
import jsondef save(path, data): path.write_text(json.dumps(data), encoding="utf-8")def load(path): return json.loads(path.read_text(encoding="utf-8"))def test_round_trip(tmp_path): path = tmp_path / "record.json" save(path, {"title": "Python"}) assert load(path) == {"title": "Python"}03Parametrize the boundaries
Parametrization runs one test function with several sets of inputs. It is ideal for boundary tables: the smallest valid value, the largest valid value, zero, one above the maximum, and non-numeric text. Each case appears separately in the report, so a failure identifies the exact input rather than only the function name.
Use a table when the assertions are the same and only the data changes. Do not force unrelated behaviors into one parametrized test just to reduce the number of functions. A clear test name and a small table are more valuable than a clever fixture that conceals the contract.
import pytestfrom ports import parse_port@pytest.mark.parametrize("text, expected", [ ("1", 1), ("80", 80), ("65535", 65535),])def test_valid_ports(text, expected): assert parse_port(text) == expected@pytest.mark.parametrize("text", ["0", "65536", "abc", ""])def test_invalid_ports(text): with pytest.raises(ValueError): parse_port(text)04Make quality automatic
Running pytest is step one. Coverage reports which lines were executed, but it does not prove the assertions are meaningful. Ruff checks style and many common mistakes quickly. A type checker reads annotations and can catch incompatible values before runtime. Put the same commands in a script, a pre-commit hook, or CI so every change follows the same path.
Order tools from fast feedback to broader checks: format, lint, type-check, test, then coverage. Do not chase a coverage percentage as a goal by itself; cover the behavior that matters, especially boundaries and failure paths. A test suite is useful when it fails for a reason you can understand and fix.
python -m pytest -qpython -m pytest --cov=srcruff check .mypy srcThe ideas worth keeping.
- pytest tests are ordinary functions with plain assertions; names determine discovery.
- Fixtures and tmp_path isolate state so tests give the same result on every run.
- Keep feedback fast: format, lint, type-check, test, and measure coverage in a repeatable workflow.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Test a slug helper
Write tests for slugify(text), which trims, lowercases, and replaces runs of non-alphanumeric characters with single hyphens. Cover a normal sentence, leading/trailing spaces, and punctuation-only input.
Explore a worked solution
import reimport pytestdef slugify(text): value = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") return value or "untitled"@pytest.mark.parametrize("text, expected", [ (" Hello, Python! ", "hello-python"), ("API v2", "api-v2"), ("!!!", "untitled"),])def test_slugify(text, expected): assert slugify(text) == expectedThe table makes each case visible. The fallback is tested explicitly because punctuation-only input would otherwise produce an empty string.
Round-trip a temporary file
Write a test that saves a dictionary to a file under tmp_path and loads it back. Assert that the loaded value equals the original and that the file exists.
Explore a worked solution
def test_json_round_trip(tmp_path): path = tmp_path / "settings.json" original = {"theme": "dark", "page": 2} save(path, original) assert path.exists() assert load(path) == originaltmp_path isolates the test from the working tree. The test checks both the file boundary and the value contract.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Test a slug helper
Write tests for slugify(text), which trims, lowercases, and replaces runs of non-alphanumeric characters with single hyphens. Cover a normal sentence, leading/trailing spaces, and punctuation-only input.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.