Protecting Architecture with Automated Tests in Python

28 January 2026, 9 min read

Protecting Architecture with Automated Tests in Python

We started our engineering careers in the Java ecosystem, where we discovered Fitness Functions and then ArchUnit, which helped us govern our codebases more effectively. Recently, we've been exploring Python and its ecosystem, and naturally, we wanted to find similar tools for Python. Today, we'd like to share one of our findings: PyTestArch.

PyTestArch is an open source library that allows users to define architectural rules and test their code against them. It is generally inspired by ArchUnit.

There's plenty of material on why it's worth automating architectural tests, so we won't repeat it here. Instead, we'll focus on how to use PyTestArch in practice through a hands-on example.

The Example Project: Naan & Pop Restaurant

For this demonstration, we created a small project that implements a simple restaurant ordering system using layered architecture principles. The project is based on the "Naan & Pop" example from the Head First Software Architecture book by Raju Gandhi, Mark Richards, and Neal Ford.

The story is straightforward: Sangita created an Indian-inspired mom-and-pop restaurant called Naan & Pop, specializing in flatbread sandwiches and sodas. The restaurant needs a website where customers can place orders online. Since Naan & Pop is a startup with a small budget, it needs to be simple and created quickly - but also extensible enough to grow with the business.

You can find the full code on GitHub.

Understanding the Layered Architecture

The codebase is organized into three main layers, each with a specific responsibility:

depends on

depends on

Presentation Layer

Workflow Layer

Persistence Layer

  • Presentation Layer: FastAPI routers, endpoints, and request/response models
  • Workflow Layer: Business logic and use case orchestration
  • Persistence Layer: Entity models, value objects, and data structures

This layered architecture was chosen for Naan & Pop because it delivers one of the most important characteristics for a startup: fast time to market. Compared to more complex patterns like hexagonal or microservices architectures, it's simple to understand and implement, allowing the team to get the system running quickly without sacrificing maintainability.

But speed isn't the only benefit. One of the most powerful advantages of layered architecture is the ease of replacing entire technical layers. For example:

  • Switching databases: Need to move from PostgreSQL to MongoDB? You only change the persistence layer - your workflow and presentation layers remain untouched because they interact with persistence through the workflow layer, which handles data transformation between presentation DTOs and persistence entities.
  • Adding new interfaces: Want to add a mobile app alongside the web interface? Add a new presentation layer (e.g., a REST API or GraphQL endpoint) without touching your existing business logic.

The key architectural rules make this possible:

  • Allowed dependencies: Presentation → Workflow and Workflow → Persistence
  • Forbidden dependencies: Presentation → Persistence (layer skipping) and any reverse dependencies (lower layers depending on higher ones)

Why is layer skipping so problematic? It breaks this replaceability. If the presentation layer directly accesses the persistence layer, then changes to your database schema or data access patterns would require updates in both the workflow layer and the presentation layer. For instance, renaming a database column from order_id to id would force you to update not only your persistence models and workflow logic but also every presentation router that directly imports those models. You've lost the ability to change one technical concern in isolation. Beyond that, bypassing the workflow layer means circumventing business logic and validation rules, potentially leading to data inconsistencies.

These rules sound simple, but in practice they require constant vigilance. As the codebase grows, it's easy to accidentally introduce forbidden dependencies. A developer might be tempted to import a persistence entity directly into a presentation router "just this once" to save time, breaking the architectural integrity that makes the system maintainable. This is where automated testing becomes essential.

Protecting Architecture with PyTestArch

This is where PyTestArch comes into play. We defined a set of architectural rules using PyTestArch syntax that enforce the correct dependencies between layers.

Setting Up the Foundation

Before we dive into the test code, PyTestArch uses pytest fixtures to configure the architecture. The evaluable fixture scans your codebase (scan all python files under src for imports and build an internal representation that can later be queried), and layered_architecture defines your layer structure. These are set up once in conftest.py and reused across all tests:

# tests/architecture/conftest.py (simplified)
import pytest
from pytestarch import EvaluableArchitecture, LayeredArchitecture, get_evaluable_architecture

@pytest.fixture(scope="session")
def evaluable(src_dir: str) -> EvaluableArchitecture:
    naan_and_pop_path = os.path.join(src_dir, "naan_and_pop")
    return get_evaluable_architecture(src_dir, naan_and_pop_path)

@pytest.fixture(scope="session")
def layered_architecture() -> LayeredArchitecture:
    return (
        LayeredArchitecture()
        .layer("presentation").containing_modules(["src.naan_and_pop.presentation"])
        .layer("workflow").containing_modules(["src.naan_and_pop.workflow"])
        .layer("persistence").containing_modules(["src.naan_and_pop.persistence"])
    )

Important: When defining layer modules, ensure module paths match how PyTestArch resolves imports from your project root. In this example, we use src.naan_and_pop.presentation because our source directory is src/. Your paths may differ based on your project structure.

Writing Architectural Rules

Now let's see how these fixtures are used in a test that prevents layer skipping:

from pytestarch import EvaluableArchitecture, LayerRule, LayeredArchitecture


def test_presentation_cannot_skip_layers(
    evaluable: EvaluableArchitecture, layered_architecture: LayeredArchitecture
) -> None:
    """
    Presentation must not directly access persistence.

    All data access must go through the workflow layer to ensure business
    logic is properly encapsulated and consistently applied.

    Violations would allow bypassing business rules and validation logic.
    """
    rule = (
        LayerRule()
        .based_on(layered_architecture)
        .layers_that()
        .are_named("presentation")
        .should_not()
        .access_layers_that()
        .are_named("persistence")
    )
    rule.assert_applies(evaluable)

How It Works

PyTestArch provides a fluent API for defining architectural rules. In the example above:

  1. LayerRule() - Creates a rule specifically for validating layer dependencies
  2. .based_on(layered_architecture) - References our layer configuration (defined in conftest.py)
  3. .layers_that().are_named("presentation") - Specifies which layer we're checking
  4. .should_not().access_layers_that().are_named("persistence") - Defines the forbidden dependency
  5. .assert_applies(evaluable) - Validates the rule against the actual codebase

The test scans all imports in the presentation layer and fails if it finds any direct imports from the persistence layer. This happens automatically during your CI pipeline, catching violations before they reach production.

Catching Violations in Action

To see PyTestArch in action, check out Pull Request #1 in our demo repository. In this PR, a developer tried to import an Order entity directly from the persistence layer into a presentation model to avoid duplicating field definitions. While this seems efficient, it creates a direct coupling between the presentation and persistence layers, breaking the architectural boundary. The architecture tests catch this violation and fail the build, preventing the problematic code from being merged.

When a violation occurs, PyTestArch provides clear error messages showing exactly which module violated the rule and what the forbidden import was. This makes it easy to understand and fix the issue quickly.

E AssertionError: "src.naan_and_pop.presentation.api.models.orders" (layer "presentation") imports "src.naan_and_pop.persistence.orders.order" (layer "persistence").

This demonstrates the real value of automated architecture tests: they act as guardrails that prevent architectural erosion over time, even as team members change and the codebase grows. The example repository includes additional tests that enforce other critical rules like preventing reverse dependencies and ensuring proper layer isolation.

Beyond Protection: Architecture as a Fitness Function

Architecture tests can do more than just prevent violations - they can actively guide refactoring efforts. In his video lesson "Fitness Function-Driven Architecture", Mark Richards discusses using ArchUnit (or similar tools in other ecosystems) as a Fitness Function that guides teams toward refactoring application architecture to a desired state.

Note: we highly recommend materials from Mark Richard's website Developer to Architect, which offers excellent resources on software architecture topics.

The approach is straightforward: write architecture tests for your target architecture before implementing it. These tests will initially fail, but they provide clear, measurable goals for your refactoring effort. As you restructure components and move code between layers, the tests gradually turn green, providing concrete feedback on your progress.

For instance, if you're restructuring your application/service codebase to support the domain better, you might write tests that enforce new boundaries between components. As you refactor, each passing test confirms you're moving in the right direction, while failing tests highlight remaining work.

This transforms architecture tests from passive guardrails into active navigation tools, helping teams systematically evolve their systems toward better architectural patterns.

Summary: Why Use PyTestArch?

PyTestArch brings the power of automated architecture testing to Python projects, offering several key benefits:

  • Prevent architectural drift: Catch violations early in the development process, before they reach production
  • Document architecture decisions: Tests serve as executable documentation that clearly expresses architectural intent
  • Enable confident refactoring: Know immediately if changes break architectural constraints - just like unit tests for architecture
  • Reduce code review burden: Automate the enforcement of architectural rules that would otherwise require manual review
  • Support team growth: Help new team members understand and respect architectural boundaries

Getting Started

Ready to try PyTestArch in your own project? Here's how to get started:

  1. Install PyTestArch: pip install pytestarch
  2. Define your layers in a pytest fixture (see our example in tests/architecture/conftest.py)
  3. Write your first architectural rule (start with something simple like preventing layer skipping)
  4. Run the tests and fix any violations
  5. Add the tests to your CI pipeline

The complete example code, including all architecture tests and the full Naan & Pop implementation, is available on GitHub. Feel free to use it as a starting point for your own projects.

When to Use Architecture Tests

Architecture tests are particularly valuable for:

  • Codebases with multiple contributors to ensure everyone adheres to the same architectural principles
  • Long-lived projects that need to maintain architectural integrity over time
  • Teams with varying experience levels who benefit from automated guardrails
  • Migration projects where you're actively refactoring toward a target architecture
  • Open source projects where contributors may not be familiar with architectural decisions

By investing a small amount of time upfront to define your architectural rules, you gain automated enforcement that pays dividends throughout the lifetime of your project.


About the authors

Maciej Laskowski

Maciej Laskowski - software architect with deep hands-on experience. Continuous Delivery evangelist, architecture trade offs analyst, cloud-native solutions enthusiast.

Tomasz Michalak

Tomasz Michalak - a hands-on software architect interested in TDD and DDD who translates engineering complexity into the language of trade-offs and goals.

© 2026, Copyright ©HandsOnArchitects.com