-
Notifications
You must be signed in to change notification settings - Fork 0
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 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 |
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 is the practice of deciding, at every class boundary, how a dependency enters the class and who owns its lifetime.
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.
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.
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.
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.
// ❌ 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_; }// ❌ 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.
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
|
- 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
- Home
- Test Architecture Patterns
- Designing for Testability
- Effective Use of Test Doubles
- Refactoring Toward Testability
- Deterministic Testing
- Testing Processes
- Test Plan Template
- Development Ticket Template
- Black-Box Testing from Doxygen
Module 01 — Code Structure
Module 02 — Unit Tests
Module 03 — Hardware Dependencies
Module 04 — Qt Signals & Slots
Module 05 — Dependency Injection