Skip to content

Test Architecture Patterns

Bernhard Trinnes edited this page Apr 16, 2026 · 1 revision

Test Architecture Patterns

Good tests do not happen by accident. They follow the same architectural thinking as production code: clear layers, controlled dependencies, and predictable boundaries. This page explains the patterns that make a test suite maintainable as a codebase grows.


The Testing Pyramid

The testing pyramid describes how to distribute tests across three levels. The key insight is that the further up the pyramid, the slower and more expensive a test becomes.

        ▲
       / \
      /   \       Integration & Hardware Tests
     /─────\      — few, slow, run on target
    /       \
   /─────────\    Component / Subsystem Tests
  /           \   — moderate count, some fakes
 /─────────────\
/               \  Unit Tests
/─────────────────\ — many, fast, fully isolated

On embedded Linux where tests only run on the target, unit tests must not depend on hardware state. They use fakes and stubs so they are fully isolated and deterministic regardless of what hardware is connected. The unit test layer should be exhaustive, covering all business logic branches and edge cases. Because each test is isolated and fast, a large number of them still completes in seconds even on constrained hardware.

Level Scope Speed Dependencies
Unit Single class or function < 1 ms All faked
Component A subsystem of collaborating classes Tens of ms Key boundaries faked
Integration / Hardware Full stack on real hardware Seconds Real hardware, real drivers

Layering: Separating What from How

A well-layered codebase has a natural test boundary at each layer interface. The goal is that each layer can be tested by faking the layer below it.

┌─────────────────────────────┐
│       Application Logic      │  ← tested with fake Services
├─────────────────────────────┤
│          Services            │  ← tested with fake Repositories/Drivers
├─────────────────────────────┤
│    Repositories / Adapters   │  ← tested with fake hardware or integration tested
├─────────────────────────────┤
│    Hardware / Vendor SDK     │  ← not unit tested; verified by integration tests
└─────────────────────────────┘

Rules:

  • Dependencies always point downward. Upper layers call lower layers, never the reverse
  • Each layer exposes an interface, not a concrete class
  • Tests for a layer inject fakes at the boundary directly below it

Dependency Control

Dependency control is the practice of deciding, at every class boundary, how a dependency enters the class and who owns its lifetime.

The Dependency Rule

A class should depend on abstractions it defines or is given — never on concretions it creates.

Pattern How the dependency enters Testability
new ConcreteType() inside constructor It doesn't; class creates it ❌ Not replaceable
Static / global instance Implicitly, via global state ❌ Tests share state
Constructor injection Passed in at construction ✅ Fully replaceable
Setter injection Passed in after construction ✅ Replaceable, optional
Parameter injection Passed in per method call ✅ Replaceable per call

Constructor injection (covered in Module 05) is the default recommendation. It makes every dependency visible in the class signature and impossible to forget.

Visualising a Healthy Dependency Graph

In a testable design, the dependency graph is a tree — no cycles, no hidden paths.

                ┌──────────────────┐
                │  DeviceMonitor   │
                └────────┬─────────┘
                         │ depends on
              ┌──────────┴──────────┐
              ▼                     ▼
        ┌──────────┐         ┌──────────┐
        │ ILogger  │         │ IClock   │
        └────┬─────┘         └────┬─────┘
             │                    │
    ┌────────┴──────┐    ┌────────┴──────┐
    │  FileLogger   │    │  SystemClock  │   ← production
    │  FakeLogger   │    │  FakeClock    │   ← tests
    └───────────────┘    └───────────────┘

DeviceMonitor never knows which concrete implementation is wired in. Tests wire in fakes; main() wires in the real implementations.


Test Double Vocabulary

The term "mock" is often used loosely. Using precise names avoids confusion when discussing test design with a team.

Type What it does When to use
Dummy Passed but never called Satisfies a parameter that is not exercised by the test
Stub Returns a fixed value Control the input to the class under test
Fake Simplified working implementation In-memory store, fake clock, fake logger
Spy Records calls for later assertion Verify a side-effect occurred
Mock Pre-programmed with expectations (GMock) Verify exact interactions — method, arguments, call count

Prefer fakes and stubs where possible. Mocks introduce coupling to implementation details and make tests brittle when internal call sequences change.


Controlling Time and I/O

Two of the most common sources of test brittleness in embedded code are real time and real I/O. Both are resolved with the same technique: inject an abstraction.

Time

// ❌ Untestable — real clock, non-deterministic
bool isTimeout() { return std::time(nullptr) > deadline_; }

// ✅ Testable — inject a clock, control time in tests
bool isTimeout(IClock& clock) { return clock.now() > deadline_; }

I/O (filesystem, serial, network)

// ❌ Untestable — writes to a real file
void save(const Data& d) { std::ofstream("data.bin") << d; }

// ✅ Testable — inject a storage interface
void save(const Data& d, IStorage& storage) { storage.write(d); }

In both cases the fix is identical: define an interface, inject it, fake it in tests.


Organising Tests to Match Code Structure

Keep test files parallel to source files. A test should be easy to find and its scope should be obvious from the filename alone.

workshop/
├── 05_dependency_injection/
│   ├── good_di_plain.cpp      ← production code
│   ├── good_di_qt.cpp         ← production code
│   └── di_tests.cpp           ← tests for both

Naming conventions that communicate scope:

Test name What it communicates
GivenTempAboveThreshold_WhenChecked_AlertIsActive Precondition → action → expected outcome (Given/When/Then)
ReportStatus_CallsLoggerExactlyOnce Method under test → observable effect
*HwIntegration* Requires real hardware — filter with --gtest_filter

Checklist: Is Your Test Architecture Healthy?

  • Each class depends on interfaces, not concrete types
  • No test touches real hardware, filesystem, or clock unless explicitly tagged as an integration test
  • Each unit test is independent — order of execution does not matter
  • A failing test tells you exactly which class and which behaviour broke
  • Integration tests are clearly separated and can be skipped with a single filter flag
  • The test suite finishes in seconds on the target, even with many tests

See also: Module 01 — Code Structure · Module 05 — Dependency Injection

Clone this wiki locally