Skip to main content
Clean Architecture 70 XP · 7 min

The Test Boundary

Tests are the outermost architectural circle — fragile tests that couple to implementation details make the system rigid; a Test API decouples test structure from application structure.

Showing
Ad (728×90)

Why this matters

Tests follow the Dependency Rule: they are the outermost circle, depending inward on the system. Nothing in the system depends on the tests. This means tests are replaceable — you can write better tests without changing production code, and you can change production code without changing tests (as long as behavior is preserved).

The fragile test problem occurs when tests are coupled to implementation details rather than behavior. A test that instantiates concrete database repositories, calls private methods, or checks specific SQL query strings will break every time the internal implementation changes — even if the observable behavior of the system remains identical. These tests slow refactoring to a crawl and provide false confidence.

The solution is a Test API: a set of in-memory stubs that implement the same abstract interfaces the production code uses. The Use Case is tested through its public input/output (DTOs), not through its internals. When the repository implementation changes from SQLAlchemy to Tortoise ORM, the tests — exercising the Use Case through the in-memory stub — stay green.

The problem

Tests coupled to concrete repositories and private internal columns — every refactor breaks the test suite even when behavior is unchanged.

Bad

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from orders.models import OrderModel    # concrete SQLAlchemy model
from orders.service import OrderService

@pytest.fixture
def session():
    engine  = create_engine("sqlite:///:memory:")
    Session = sessionmaker(bind=engine)
    OrderModel.metadata.create_all(engine)
    return Session()

def test_place_order_saves_to_db(session):
    service = OrderService(session)  # depends on real session
    service.place_order("user-1", [{"sku": "A1", "qty": 1, "price": 10.0}])

    # Checking internal DB state — implementation detail
    row = session.query(OrderModel).filter_by(user_id="user-1").first()
    assert row._status_code == 1   # private column name!

# Rename _status_code: test breaks.
# Switch from SQLAlchemy to Tortoise: all tests break.
# Tests are testing HOW, not WHAT.
import { DataSource }        from "typeorm";
import { OrderEntity }       from "./OrderEntity";
import { PlaceOrderUseCase } from "./PlaceOrderUseCase";
import { TypeOrmOrderRepo }  from "./TypeOrmOrderRepo";

describe("PlaceOrder", () => {
  it("saves order to DB", async () => {
    const ds = new DataSource({ type: "sqlite", database: ":memory:",
      entities: [OrderEntity], synchronize: true });
    await ds.initialize();

    const repo    = new TypeOrmOrderRepo(ds);   // concrete dependency
    const useCase = new PlaceOrderUseCase(repo);
    await useCase.execute({ userId: "u1", items: [{ sku: "A1", qty: 1, price: 10 }] });

    // Checking private DB column:
    const raw = await ds.getRepository(OrderEntity).findOneBy({ userId: "u1" });
    expect(raw!._internalStatusCode).toBe(1);  // breaks when column is renamed
  });
});

The solution

An in-memory stub implements the same abstract interface. Tests exercise the Use Case through its public DTO input/output — behavior, not implementation. Refactoring internals leaves the tests green.

Good

from orders.use_cases import PlaceOrderUseCase
from orders.repositories import OrderRepository  # abstract interface
from orders.dtos import PlaceOrderRequest

class InMemoryOrderRepository(OrderRepository):
    def __init__(self):
        self._store = {}

    def save(self, order) -> str:
        self._store[order.order_id] = order
        return order.order_id

    def find_by_id(self, order_id: str):
        return self._store.get(order_id)

def test_place_order_returns_pending_order():
    repo     = InMemoryOrderRepository()
    use_case = PlaceOrderUseCase(repo)
    request  = PlaceOrderRequest(user_id="user-1",
                                 items=[{"sku": "A1", "qty": 1, "price": 10.0}])

    response = use_case.execute(request)

    assert response.order_id is not None
    assert response.status == "pending"

# No DB, no Flask, no network — runs in milliseconds.
# Refactor: rename any internal field — test stays green.
# Replace SQLAlchemy with anything — test stays green.
import { PlaceOrderUseCase } from "./PlaceOrderUseCase";
import { OrderRepository }   from "./OrderRepository";  // abstract interface
import { Order }             from "./Order";

class InMemoryOrderRepository implements OrderRepository {
  private store = new Map<string, Order>();
  async save(order: Order): Promise<string> { this.store.set(order.orderId, order); return order.orderId; }
  async findById(id: string): Promise<Order | null> { return this.store.get(id) ?? null; }
}

describe("PlaceOrderUseCase", () => {
  it("returns a pending order id", async () => {
    const repo     = new InMemoryOrderRepository();
    const useCase  = new PlaceOrderUseCase(repo);
    const response = await useCase.execute({
      userId: "u1", items: [{ sku: "A1", qty: 1, price: 10 }]
    });

    expect(response.orderId).toBeTruthy();
    expect(response.status).toBe("pending");
  });
});
// No DB, no TypeORM, no network — < 1ms.
// Refactor internals freely — tests stay green as long as behavior is unchanged.

Key takeaway

Tests that break when you refactor without changing behavior are testing implementation, not behavior. They slow you down instead of protecting you. Use in-memory stubs behind abstract interfaces and exercise the system through its public Use Case DTOs. Tests that survive refactoring are the ones worth writing.

Done with this lesson?

Mark it complete to earn XP and track your progress.