Skip to content
← All Python articles

Python explainer

Test Your Module Boundaries in CI

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

Turn a rule such as "the domain never imports the database" into checks that fail CI, with Import Linter contracts and a test that blocks a module.

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

Your pricing package keeps its calculation rules in pricing.domain and its database code in pricing.adapters.sqlite. The domain must never import the database code. Nothing stops a teammate from adding that import next month, and code review can miss it.

Turn the rule into a check that runs in CI. CI, or continuous integration, is the set of checks that run on every push or pull request. A broken boundary then fails the build with a message that names it. Tool docs call an automated check of a design rule like this a fitness function.

Check the import graph with Import Linter

Import Linter reads your source, builds a graph of which modules import which, and checks it against rules in pyproject.toml. Run it with lint-imports in the project's environment:

[tool.importlinter]
root_package = "pricing"
include_external_packages = true

[[tool.importlinter.contracts]]
name = "Dependencies follow the layers"
type = "layers"
layers = ["pricing.entrypoints", "pricing.adapters", "pricing.application", "pricing.domain"]

[[tool.importlinter.contracts]]
name = "Domain does not import database or HTTP libraries"
type = "forbidden"
source_modules = ["pricing.domain"]
forbidden_modules = ["sqlite3", "urllib", "httpx"]

[[tool.importlinter.contracts]]
name = "Adapters are independent"
type = "independence"
modules = ["pricing.adapters.sqlite", "pricing.adapters.http"]

The layers contract lists layers from outermost to innermost, the reverse of layer_order in the layering exercise. A layer may import the layers listed after it, and not the ones before it.

The forbidden contract stops pricing.domain from importing the listed packages, and by default it checks indirect imports too. If pricing.domain imports pricing.helpers, and pricing.helpers imports sqlite3, the contract fails. For a package outside your project, such as sqlite3, set include_external_packages = true and list the package's top-level name.

The independence contract stops the two adapters from importing each other.

Check your understanding Does moving import sqlite3 inside a function make the forbidden contract pass? Show answer

No. Import Linter reads every import statement in the source, including the ones inside functions. Moving the import changes when Python runs it, not whether the module depends on sqlite3.

Import Linter cannot see a module name built at runtime, such as importlib.import_module(name). To test your setup, add a known bad import on a branch and check that CI fails.

Check that a function runs without a dependency

A test can block a module and then call your code in a fresh Python process. Suppose the installed pricing/domain.py holds:

from collections.abc import Sequence


def total(prices_in_cents: Sequence[int]) -> int:
    return sum(prices_in_cents)

Its pricing/__init__.py re-exports total and lists it in __all__. Install a built wheel of the package into a clean virtual environment, then run this test with that environment's Python:

import subprocess
import sys

code = '''
import os
import sys

sys.modules["sqlite3"] = None
sys.modules["httpx"] = None
os.environ.clear()

from pricing.domain import total
assert total([250, 175]) == 425
import pricing
assert all(hasattr(pricing, name) for name in pricing.__all__)
'''
subprocess.run([sys.executable, "-I", "-c", code], check=True)

A None entry in sys.modules makes any later import of that name fail, and os.environ.clear() removes every environment variable in the child. Importing pricing.domain runs pricing/__init__.py first, so the test also fails if that file loads sqlite3. The test calls total instead of only importing it, because a function can import a module inside its body. The last two lines check that every name in __all__ exists. -I keeps the child from reading your current folder, PYTHON* environment variables and your per-user site-packages, so it imports the installed copy. Install the wheel, not an editable install, which would point back at your source.

The test covers only the code that this one call runs. Another branch in total can still import a blocked module.

Check that adapters behave the same

Import rules cannot tell you whether two adapters do the same thing. Run the same cases through the in-memory adapter and the real database adapter, and compare the results and errors. Include missing keys, ordering, rounding and null values where your Protocol's rules cover them.

Check your understanding Both adapters fit the same Protocol. Why compare their outputs? Show answer

A Protocol fixes method names and types. Two adapters can fit it and still disagree about a missing key or a rounding rule. Running both on the same inputs shows those differences for the cases you test.

Start with one rule

Pick the one boundary that matters most in your project. Add an import contract for it, a behavior test, and a known bad example that proves the check fails. Write each check so that its failure message tells you what to fix.

Going further

A small ast check can enforce source rules that no import contract covers, such as a ban on bare except:. Rules about particular function calls need more care, because a simple name match misses aliases, wrappers and dynamic calls. Test such a check with examples that should pass and fail.

step 19 of 41

Modules, Packaging and Tooling

Up next Find Import Chains That Reach a Forbidden Package