CHAPTER 36 · INTERMEDIATE4 min read

Pytest and modern quality tools

Move from a single test file to a repeatable workflow with fixtures, parametrization, coverage, linting, and type checks.

Python 3.12+Free to readModule 8 of 13
YOUR NEXT SMALL WINS

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.

test_ports.pyPython
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.

test_storage.pyPython
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.

test_port_table.pyPython
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.

terminalShell
python -m pytest -qpython -m pytest --cov=srcruff check .mypy src
LET IT CLICK

The 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.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

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.

01

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.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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) == expected

The table makes each case visible. The fallback is tested explicitly because punctuation-only input would otherwise produce an empty string.

02

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.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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) == original

tmp_path isolates the test from the working tree. The test checks both the file boundary and the value contract.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01Which file name does pytest discover by default?
02What is a pytest fixture for?
03Does 100% line coverage prove that a test suite is correct?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

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.

EXPECTED OUTPUT
[Process exited with code 0 - No output]
challenge.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

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.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock chapter completion.