Testable Design
Design code for testability using boundary isolation, dependency injection, and testable architecture patterns. Use when designing test strategies, writing unit tests, improving test coverage, refactoring untestable or hard-to-test code, working with mocking or test doubles, or applying TDD practices.
npx skills add github:pantheon-org/tekhne --skill software-engineering--design-principles--testable-designArchitecture and design patterns for testable code, focusing on boundary isolation and dependency injection.
Philosophy
- Treat difficulty in testing as feedback about the design, not as a testing problem.
- Fix coupling rather than compromising the architecture to make a test pass.
- Test behaviour at architectural boundaries, not the internals behind them.
- Prefer small, fast unit tests over pure logic, and a few integration tests at each adapter.
When to Use
- Designing test strategies for new features
- Refactoring untestable code with hard dependencies
- Improving test coverage by isolating layers
- Evaluating whether code is testable before implementation
- Designing boundaries that enable fast unit tests
- Reviewing a test suite that relies on static mocking, real databases or skipped tests
When Not to Use
- Class-level SOLID violations (use solid-principles)
- Architectural boundary decisions (use clean-architecture)
- Choosing structural patterns (use design-patterns)
- Writing a test-first workflow from scratch (use test-driven-development)
Testable Design Principles
Tests Are Architecture
If code is hard to test, it’s a design problem, not a testing problem.
Boundary Verification
Test at architectural boundaries, not internal implementation details:
- Unit tests: Test pure business logic (entities, use cases) in isolation
- Integration tests: Test boundary adapters (repositories, controllers, gateways)
- End-to-end tests: Test complete user workflows across boundaries
Layer Isolation
Each layer should be testable in isolation:
- Entities: Pure functions, no dependencies
- Use Cases: Depend on interfaces (ports), not concrete implementations
- Adapters: Test with fake use cases or real infrastructure
- Infrastructure: Test with integration tests, not unit tests
Workflow
Step 1: Identify Hard-to-Test Code
Output: List of testability blockers.
Example:
Testability blocker: OrderService instantiates PostgresRepository in constructor.
Problem: Cannot unit test OrderService without a real database.
Refactor: Inject IOrderRepository interface; provide mock in tests.
Step 2: Apply Dependency Injection
Output: Dependencies injected via constructor or method parameters.
Template:
// Before: hard to test (concrete dependency)
class OrderService {
private repo = new PostgresOrderRepository()
async createOrder(input: OrderInput): Promise<Order> {
// Logic using this.repo
}
}
// After: testable (injected interface)
class OrderService {
constructor(private repo: IOrderRepository) {}
async createOrder(input: OrderInput): Promise<Order> {
// Logic using this.repo (mockable in tests)
}
}
Step 3: Isolate Side Effects
Output: Pure business logic separated from side effects.
Use the Humble Object pattern:
- Humble object: Thin adapter with minimal logic (controller, presenter, gateway)
- Testable object: Pure logic with no infrastructure dependencies (use case, entity)
Example:
// Humble object (minimal logic, hard to test)
class HttpController {
constructor(private useCase: CreateOrderUseCase) {}
async handle(req: Request, res: Response): Promise<void> {
const input = { userId: req.body.userId, items: req.body.items }
const output = await this.useCase.execute(input)
res.json(output)
}
}
// Testable object (pure logic, easy to test)
class CreateOrderUseCase {
constructor(private repo: IOrderRepository) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
// Business logic here (unit testable with mock repo)
}
}
Step 4: Design Test Doubles
Output: Mocks, stubs, or fakes for dependencies.
Choose appropriate test double:
- Stub: Returns fixed data (for queries)
- Mock: Verifies behavior (for commands)
- Fake: Lightweight implementation (in-memory DB)
Example:
// Stub (for queries)
class StubOrderRepository implements IOrderRepository {
async findById(id: string): Promise<Order | null> {
return new Order({ id, status: 'pending' })
}
}
// Mock (for commands)
class MockOrderRepository implements IOrderRepository {
saveWasCalled = false
async save(order: Order): Promise<void> {
this.saveWasCalled = true
}
}
// Fake (lightweight alternative)
class InMemoryOrderRepository implements IOrderRepository {
private orders = new Map<string, Order>()
async save(order: Order): Promise<void> {
this.orders.set(order.id, order)
}
async findById(id: string): Promise<Order | null> {
return this.orders.get(id) || null
}
}
Step 5: Verify Boundary Tests
Output: Test coverage at each architectural boundary.
Check:
- Entities are unit tested (pure logic, no dependencies)
- Use cases are unit tested (with mocked ports)
- Adapters are integration tested (with real or fake infrastructure)
- Controllers are integration tested (with real use cases or fakes)
Example:
Unit test: CreateOrderUseCase with mock IOrderRepository
Integration test: PostgresOrderRepository with real database
End-to-end test: POST /orders with real HTTP server and database
Anti-Patterns
NEVER instantiate concrete dependencies in business logic
WHY: A hard-wired dependency cannot be replaced in a test, so the class needs real infrastructure to run.
BAD: new PostgresRepository() in the OrderService constructor.
GOOD: Depend on the IRepository interface; inject the implementation.
NEVER test implementation details
WHY: Tests coupled to internal calls break on every refactor while saying nothing about behaviour.
BAD: Test that method X calls method Y internally. GOOD: Test public behaviour: given input, expect output.
NEVER use real infrastructure in unit tests
WHY: Real databases and networks make unit tests slow, flaky and unable to pinpoint a failure.
BAD: Unit test connects to a real database. GOOD: Unit test uses a mock repository; integration test uses the real database.
NEVER skip tests because code is “hard to test”
WHY: Hard-to-test code is a design signal; skipping the test hides the coupling and ships untested code.
BAD: Skip the test, ship untested code. GOOD: Refactor the code to be testable (dependency injection, layer isolation).
NEVER tangle business logic with infrastructure
WHY: Mixed logic and infrastructure forces every business rule test to set up SQL, HTTP or filesystems.
BAD: OrderService contains SQL queries and HTTP response formatting.
GOOD: OrderService orchestrates pure entities; adapters handle infrastructure.
NEVER depend on static calls or singletons for behaviour
WHY: Statics and singletons are hidden dependencies that need static-mocking tools and shared-state resets in every test.
BAD: InventoryChecker.isAvailable(items) and NotificationService.getInstance() inside the processor.
GOOD: Inject an inventory interface and a notification port, and pass instances.
NEVER read the current time or random numbers directly in logic
WHY: Direct access to the clock or randomness makes results non-deterministic, so tests cannot assert exact values.
BAD: order.setProcessedAt(new Date()) inside the service.
GOOD: Inject a Clock and call clock.now(); tests supply a fixed time.
NEVER leave dependency rules unenforced
WHY: Without automated checks, shortcuts such as framework imports in the domain accumulate until refactoring becomes impossible.
BAD: Rely on code review to notice import prisma in the domain layer.
GOOD: Run architecture tests (for example dependency-cruiser or ArchUnit) in CI so violations fail the build.
NEVER compromise the architecture for testability
WHY: Test-only hooks and exposed internals hide the real coupling and leave the design worse than before.
BAD: Make a private method public, or add a test flag, so a test can reach it. GOOD: Fix the coupling by extracting a collaborator and injecting it.
Quick Commands
# Find hard-to-test code (concrete instantiation)
rg -n "new [A-Z].*Repository\(|new [A-Z].*Service\(|new [A-Z].*Gateway\(" src
# Find side effects in business logic
rg -n "fetch\(|axios\.|fs\.|process\.env" src/domain src/application
# Run tests with coverage
npm run test:coverage
References
| Topic | Reference | When to Use |
|---|---|---|
| Boundary verification | references/test-boundary-verification.md | Adding architecture tests that enforce dependency rules in CI |
| Testable design | references/test-testable-design.md | Removing hidden dependencies (statics, singletons, clock) |
| Layer isolation | references/test-layer-isolation.md | Deciding how each layer is tested in isolation |
| Tests are architecture | references/test-tests-are-architecture.md | Reviewing test suites that mock internals or sit apart from the design |
Related Patterns
For dependency inversion (DIP), see solid-principles. For boundary design, see clean-architecture. For Humble Object pattern, see design-patterns.
Further Reading
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
No eval scenarios available.
Verify Architectural Boundaries with Tests
Use automated tests to enforce that dependency rules are followed. Architecture tests catch violations before they become entrenched patterns.
Incorrect (no boundary verification):
// Over time, developers add shortcuts
// domain/Order.java
import org.springframework.stereotype.Component; // Framework in domain!
import javax.persistence.Entity; // JPA in domain!
// application/CreateOrderUseCase.java
import com.stripe.Stripe; // Direct payment vendor dependency!
import infrastructure.email.SendGridClient; // Infrastructure in application!
// No tests catch these violations
// They accumulate until refactoring becomes impossible
Correct (architecture tests enforce boundaries):
// Using ArchUnit (Java) or similar tools
@AnalyzeClasses(packages = "com.myapp")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
@ArchTest
static final ArchRule domain_should_not_use_frameworks =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"javax.persistence..",
"jakarta.persistence.."
);
@ArchTest
static final ArchRule usecases_should_not_access_controllers =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("..interface..");
@ArchTest
static final ArchRule dependencies_point_inward =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Infrastructure").definedBy("..infrastructure..")
.layer("Interface").definedBy("..interface..")
.whereLayer("Domain").mayNotAccessAnyLayer()
.whereLayer("Application").mayOnlyAccessLayers("Domain")
.whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application")
.whereLayer("Interface").mayOnlyAccessLayers("Domain", "Application");
}
// TypeScript equivalent using dependency-cruiser
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: 'domain-no-infra',
from: { path: '^src/domain' },
to: { path: '^src/infrastructure' }
},
{
name: 'domain-no-frameworks',
from: { path: '^src/domain' },
to: { path: 'node_modules/(express|prisma|typeorm)' }
}
]
};
Run in CI:
# .github/workflows/ci.yml
- name: Check architecture
run: |
./gradlew archTest # Java with ArchUnit
npx depcruise src # TypeScript with dependency-cruiser
Benefits:
- Violations caught immediately in CI
- New developers can’t accidentally break boundaries
- Architecture documentation that stays accurate
Reference: ArchUnit
Test Each Layer in Isolation
Each architectural layer should be testable independently. Domain tests need no infrastructure. Use case tests need no web framework. This enables fast, focused testing.
Incorrect (everything tested through API):
// All tests go through HTTP - slow, imprecise failures
describe('Order API', () => {
let app: Express
let db: Database
beforeEach(async () => {
db = await Database.connect()
await db.migrate()
await db.seed()
app = createApp(db)
})
afterEach(async () => {
await db.clean()
await db.close()
})
it('rejects order with insufficient inventory', async () => {
// 500ms+ per test
const response = await request(app)
.post('/orders')
.send({ items: [{ productId: 'p1', quantity: 1000 }] })
expect(response.status).toBe(400)
expect(response.body.error).toBe('Insufficient inventory')
})
// 50 more tests like this - test suite takes 5 minutes
// When one fails, unclear if it's domain, use case, or HTTP issue
})
Correct (layered testing):
// Domain layer tests - instant, no dependencies
describe('Order', () => {
it('calculates total from items', () => {
const order = new Order()
order.addItem(new Product('p1', Money.dollars(10)), 2)
order.addItem(new Product('p2', Money.dollars(5)), 1)
expect(order.total).toEqual(Money.dollars(25))
})
it('prevents adding item with zero quantity', () => {
const order = new Order()
expect(() => order.addItem(product, 0)).toThrow(InvalidQuantityError)
})
})
// Use case tests - fast, test doubles for ports
describe('PlaceOrderUseCase', () => {
it('rejects order when inventory insufficient', () => {
const orders = new InMemoryOrderRepository()
const inventory = new FakeInventory({ 'p1': 5 }) // Only 5 in stock
const useCase = new PlaceOrderUseCase(orders, inventory)
const result = useCase.execute({
items: [{ productId: 'p1', quantity: 10 }] // Want 10
})
expect(result.isFailure).toBe(true)
expect(result.error).toBe('INSUFFICIENT_INVENTORY')
})
})
// Infrastructure tests - verify adapters work correctly
describe('PostgresOrderRepository', () => {
it('persists and retrieves order', async () => {
const repo = new PostgresOrderRepository(testDb)
const order = createTestOrder()
await repo.save(order)
const retrieved = await repo.findById(order.id)
expect(retrieved).toEqual(order)
})
})
// API tests - few, verify wiring only
describe('POST /orders', () => {
it('returns 201 when order placed successfully', async () => {
// Most logic tested above; this verifies HTTP wiring
const response = await request(app)
.post('/orders')
.send(validOrderPayload)
expect(response.status).toBe(201)
expect(response.body).toHaveProperty('orderId')
})
})
Test pyramid:
/\
/ \ E2E: ~5 tests (slow, expensive)
/----\
/ \ Integration: ~20 tests (medium)
/--------\
/ \ Unit: ~200 tests (fast, cheap)
/____________\
Reference: The Test Pyramid
Design for Testability From the Start
Clean architecture makes testing easy by design. If something is hard to test, it’s a sign of architectural coupling. Don’t compromise architecture for testability; fix the coupling.
Incorrect (hard to test, requiring workarounds):
public class OrderProcessor {
public void process(Order order) {
// Hard to test: direct instantiation
PaymentService payment = new PaymentService();
// Hard to test: static call
if (InventoryChecker.isAvailable(order.getItems())) {
// Hard to test: singleton
payment.charge(order.getTotal());
NotificationService.getInstance().sendConfirmation(order);
// Hard to test: current time
order.setProcessedAt(new Date());
}
}
}
// Test requires PowerMock, static mocking, singleton reset
// Test is slow, brittle, and complicated
@Test
@PrepareForTest({InventoryChecker.class, NotificationService.class})
public void testProcess() {
PowerMockito.mockStatic(InventoryChecker.class);
PowerMockito.when(InventoryChecker.isAvailable(any())).thenReturn(true);
// ... 20 more lines of mock setup
}
Correct (testable by design):
public class OrderProcessor {
private final InventoryChecker inventory;
private final PaymentGateway payment;
private final NotificationPort notification;
private final Clock clock;
// All dependencies injected - easy to substitute
public OrderProcessor(
InventoryChecker inventory,
PaymentGateway payment,
NotificationPort notification,
Clock clock
) {
this.inventory = inventory;
this.payment = payment;
this.notification = notification;
this.clock = clock;
}
public ProcessResult process(Order order) {
if (!inventory.isAvailable(order.getItems())) {
return ProcessResult.unavailable();
}
PaymentResult paymentResult = payment.charge(order.getTotal());
if (!paymentResult.isSuccessful()) {
return ProcessResult.paymentFailed(paymentResult.getError());
}
order.markProcessed(clock.now());
notification.sendConfirmation(order);
return ProcessResult.success(order);
}
}
@Test
void processesOrderWhenInventoryAvailable() {
var inventory = StubInventory.withAvailability(true);
var payment = FakePaymentGateway.alwaysSucceeds();
var notification = new SpyNotification();
var clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneOffset.UTC);
var processor = new OrderProcessor(inventory, payment, notification, clock);
var result = processor.process(order);
assertThat(result.isSuccessful()).isTrue();
}
Testability checklist:
- No
newfor services (inject dependencies) - No static method calls for behavior (use interfaces)
- No singletons (pass instances)
- No direct time/random access (inject Clock, Random)
- No hidden dependencies (everything in constructor)
Reference: Growing Object-Oriented Software, Guided by Tests
Tests Are Part of the System Architecture
Tests participate in the architecture like any other component. They follow the dependency rule, couple to stable APIs, and should be designed for maintainability.
Incorrect (tests as afterthought):
# tests/test_everything.py - Monolithic test file
import pytest
from unittest.mock import patch, MagicMock
class TestOrders:
@patch('app.services.order_service.db')
@patch('app.services.order_service.stripe')
@patch('app.services.order_service.email_sender')
@patch('app.services.order_service.inventory')
def test_create_order(self, mock_inv, mock_email, mock_stripe, mock_db):
# Testing implementation details
mock_db.query.return_value.filter.return_value.first.return_value = Customer(id=1)
mock_inv.check.return_value = True
mock_stripe.PaymentIntent.create.return_value = MagicMock(id='pi_123')
from app.services.order_service import create_order
result = create_order({'customer_id': 1, 'items': [...]})
# Asserting on internal calls, not behavior
mock_db.query.assert_called()
mock_stripe.PaymentIntent.create.assert_called_once()
# Tests coupled to implementation, break with refactoring
# Tests slow because they patch everything
Correct (tests designed as architecture component):
# tests/unit/domain/test_order.py - Fast, stable, test domain rules
class TestOrder:
def test_calculates_total_from_line_items(self):
order = Order.create(customer_id="c1")
order.add_item(Product("p1", Money(100)))
order.add_item(Product("p2", Money(50)))
assert order.total == Money(150)
def test_rejects_negative_quantity(self):
order = Order.create(customer_id="c1")
with pytest.raises(InvalidQuantityError):
order.add_item(Product("p1", Money(100)), quantity=-1)
# tests/integration/application/test_create_order.py - Test use case
class TestCreateOrderUseCase:
def test_creates_order_and_reserves_inventory(self):
# Use test doubles, not mocks of internals
orders = InMemoryOrderRepository()
inventory = FakeInventoryService(available={"p1": 10})
use_case = CreateOrderUseCase(orders, inventory)
result = use_case.execute(CreateOrderCommand(
customer_id="c1",
items=[OrderItem("p1", quantity=2)]
))
# Assert on observable behavior
assert orders.find_by_id(result.order_id) is not None
assert inventory.reserved["p1"] == 2
# tests/e2e/test_order_flow.py - Full flow, few tests
class TestOrderFlow:
def test_complete_order_journey(self, api_client, test_db):
# Create order via API
response = api_client.post('/orders', json={...})
order_id = response.json['order_id']
# Verify order persisted
order = test_db.query(Order).get(order_id)
assert order.status == 'pending'
Test architecture mirrors system:
tests/
├── unit/ # Fast, isolated
│ ├── domain/ # Entity business rules
│ └── application/ # Use case logic
├── integration/ # Component interaction
│ ├── persistence/ # Repository implementations
│ └── external/ # Gateway implementations
└── e2e/ # Full system
└── api/ # HTTP endpoints
Benefits:
- Unit tests run in milliseconds
- Refactoring doesn’t break tests
- Tests document intended behavior
Reference: Clean Architecture - The Test Boundary