Skip to content
← All Python articles

Python explainer

Seven Ways to Test Code Structure

About 6 min to read. Allow extra time to work through examples.

Turn a design rule into a test that fails when someone breaks it. Pass in fakes, block an import, check the import graph and the public names, keep bad calls as type errors, scan the AST, and compare two implementations.

This article uses Python. You can make it your practice mode to browse related content across the site.

The earlier items in this track gave you design rules, such as "the domain layer never imports the database". Nothing enforces a rule you write only in a README. A test does, because it fails on the commit that breaks the rule.

Start with a specific rule. "The design is good" is too vague to test. "Computing an order total must work without SQLite" tells you what to run and what a failure means. Here are seven ways to turn rules like that into checks.

1. Pass in a fake dependency

When code receives a dependency as an argument, a test can pass in one it controls. A fake clock lets you test exactly when a token expires:

from collections.abc import Callable

def expired(deadline: float, now: Callable[[], float]) -> bool:
    return now() >= deadline

assert not expired(10.0, lambda: 9.0)
assert expired(10.0, lambda: 10.0)
assert expired(10.0, lambda: 11.0)

The same idea gives you a store that returns nothing, or a service that fails on its third call. Make the fake produce the cases your code has to handle, and no more.

2. Block a module and run the code

To show that some code runs without a dependency, run it in a fresh Python process where importing that dependency fails. This script writes a small module to a temporary folder, then tests it in a child process with sqlite3 blocked:

from pathlib import Path
import subprocess
import sys
import tempfile

source = (
    '__all__ = ["total"]\n'
    'def total(prices: list[int]) -> int:\n'
    '    return sum(prices)\n'
)
check = (
    'import sys\n'
    'sys.path.insert(0, sys.argv[1])\n'
    'sys.modules["sqlite3"] = None\n'
    'import pricing_domain as domain\n'
    'assert domain.total([120, 80]) == 200\n'
    'assert domain.total([]) == 0\n'
)
with tempfile.TemporaryDirectory() as directory:
    Path(directory, "pricing_domain.py").write_text(source, encoding="utf-8")
    subprocess.run(
        [sys.executable, "-I", "-c", check, directory],
        check=True,
        timeout=10,
    )

Setting sys.modules["sqlite3"] = None makes every later import sqlite3 raise ModuleNotFoundError. That includes an import inside total, which runs only when something calls total. Add import sqlite3 above the return line in source and run the script again. The check fails.

The -I flag starts Python in isolated mode. It ignores PYTHON* environment variables. It also leaves the current folder and your user site-packages off sys.path, the list of folders Python searches when you import a module. So the child process imports pricing_domain only from the folder that the check adds.

The check blocks one name and runs two calls. Block every module the rule names, and make one call for each branch you want to test.

3. Check which modules import which

An import graph records which modules import which others. A rule can forbid a direct import, or any chain of imports between two parts of the code. In the chain domain -> helpers -> database, the domain depends on the database even though it never imports it directly.

The layering and forbidden-chain exercises earlier in this track build this check by hand. In a real project, a tool such as Import Linter runs it from a configuration file. You decide which modules belong to each layer and whether indirect imports count. A graph built from the source misses an import whose module name the code builds at runtime, such as importlib.import_module(name).

4. Pin the public names

A module's __all__ lists its public names. Add these lines to the check string in section 2, after the line that imports domain:

assert set(domain.__all__) == {"total"}
assert callable(domain.total)

The first line fails when someone adds or removes an export. The second checks that the exported function exists. Neither checks what total returns, so keep the calculation tests too. __all__ decides what from pricing_domain import * brings in, and it tells readers what is public. Other names in the module stay importable.

5. Keep a bad call a type error

Types can rule out a call that makes no sense. In this example, you must seal a draft before you can publish it:

from dataclasses import dataclass

@dataclass(frozen=True)
class Draft:
    text: str

@dataclass(frozen=True)
class Sealed:
    text: str

def seal(draft: Draft) -> Sealed:
    return Sealed(draft.text)

def publish(document: Sealed) -> str:
    return document.text

draft = Draft("hello")
publish(seal(draft))  # Valid call.
publish(draft)  # type: ignore[arg-type]

Save it as type_contract.py and run mypy --strict type_contract.py. The comment on the last line silences the expected arg-type error, so mypy passes. --strict includes --warn-unused-ignores. If someone changes publish to accept Draft | Sealed, the comment silences nothing, and mypy reports unused-ignore. The loosened type now fails CI.

Run this file with mypy, not pytest. Python runs publish(draft) without complaint, because it does not check annotations at runtime. Write the error code in each ignore comment, as in [arg-type], so the comment cannot hide a different error. Keep a valid call next to each invalid one, so the file also checks that the right call still passes.

6. Scan the source with the AST

The AST, or abstract syntax tree, is the tree of node objects that ast.parse builds from source text without running the code. You used it in the ast exercises earlier in this track. It suits rules about what the source says, such as "no bare except:":

import ast

source = '''
try:
    risky()
except:
    pass
'''
bare = [
    node.lineno
    for node in ast.walk(ast.parse(source))
    if isinstance(node, ast.ExceptHandler) and node.type is None
]
assert bare == [4]

Rules about what runs at import time need more care. A function body runs only when something calls the function, but its decorators and default values run when the def statement runs. A class body runs when Python creates the class. Python 3.14 evaluates annotations only when something asks for them. A plain name match also misses aliases, as in from threading import Thread as T. Test a scanner on code it should accept as well as code it should reject.

7. Compare two implementations

Differential testing runs the same cases through two implementations and compares the results. For example, give an in-memory store and a SQLite store the same missing keys, repeated writes and empty results. Check that they agree on the results, their order and the exceptions. Both versions can share a bug, so add a few cases whose answers you worked out by hand.

Metamorphic testing changes the input in a known way and checks how the output changes. For the total function above, shuffling the prices must give the same total, and adding a free item must not change it. You get many tests without writing out each expected answer. Pick relations that hold for every input. Shuffling a list of floats can change its sum in the last digit.

Check your understanding How would you check that importing a library does not start any threads? Show answer

Use two checks. A scan of the AST flags known thread-starting calls that run at import time, including calls in default values, decorators and class bodies. A test in a fresh process replaces threading.Thread.start with a wrapper that records each call, then imports the library. The wrapper also sees a thread that finishes before the import returns, which a count of running threads taken afterwards would miss. Neither check sees a thread that a C extension starts.

step 21 of 41

Modules, Packaging and Tooling

Up next Build a Wheel and Install It