This article describes a shift in my development habits. I used to default to mirrored source and test trees, and now I tend to co-locate tests. Below, I cover the structure and the practicalities of organizing a build system to support this choice.
Table of Contents:
Mirrored Trees
In most embedded codebases I’ve worked in, test files (if they even exist!) live in a separate test/ directory tree that mirrors the source layout.
project/
├── src/
│ ├── sensors/
│ │ ├── atmospheric_pressure.c
│ │ └── humidity.c
│ └── spi/
│ └── spi.c
└── test/
├── sensors/
│ ├── atmospheric_pressure_test.c
│ └── humidity_test.c
├── spi/
└── spi_test.c
└── test_runner.c
There are benefits to this approach:
- Clean separation at the filesystem level
- Test programs are often quite different from the embedded applications – different compiler flags, linker settings, runtime environments, and even different toolchains
- You can trivially exclude tests from the build: to disable tests, don’t descend into the
test/directory
if get_option('enable-tests')
subdir('test')
endif
And yet, it is not without its downsides, most notably in terms of “out of sight, out of mind.”
- It’s easy to neglect to add or edit tests while working on a given source file.
- It’s easy to overlook files that have no tests altogether.
- You incur context-switching overhead – imagine you’re focused on your work, want to check the tests, and now you’re pulled out of your focused state in order to navigate to a different part of the repository. Not catastrophic, but an additional level of friction nonetheless.
- Structure can drift over time as organizational changes are made in the
src/tree but not mirrored intest/, which incurs further context-switching overhead.
Co-location
I have moved toward co-locating tests with the source files. If you’re editing a source file, the tests are right there next to it. It reinforces the connection and makes it easier to jump directly to the tests.
project/
├── src/
│ ├── sensors/
│ │ ├── atmospheric_pressure.c
│ │ ├── atmospheric_pressure_test.c
│ │ ├── humidity.c
│ │ └── humidity_test.c
│ └── spi/
│ ├── spi.c
│ └── spi_test.c
└── test/
└── test_runner.c
The test tree itself will still exist (at least, it does in my projects). That’s because we still need a home for the test infrastructure:
- Test harness setup
- Test build target definitions
- Mock infrastructure and recorded data
- Shared testing utilities
Build System Structure
Moving test files next to the source is easy enough, but it requires some thought in how the build is implemented. After all, this isn’t a win if you’re adding a bunch of friction or complication to your build files. For example, we don’t need to check an enable-tests option throughout the entire source tree.
The approach I’ve settled on is having each directory or build module make a list of test files that it exports. These could be added to individual module lists or appended to a general test source list.
I tend to use a general test source files list that gets appended to, as I can flexibly adjust the contents of the list based on which modules get included at build time.
# src/sensors/meson.build
sensors_lib = add_library(sensors,
sources: [
'atmospheric_pressure.c',
'humidity.c',
],
)
# Append test sources — no build targets here
all_tests_src += files(
'atmospheric_pressure_test.c',
'humidity_test.c',
)
The top-level build file retains the guard before descending into the test/ tree. Note that all_tests_src must be declared before subdir('src') is called. Meson’s variable scoping requires the list to exist before subdirectories append to it.
# /meson.build (top level)
# In order for appending to work with Meson's variable scoping,
# we create the empty list before processing the src tree.
all_tests_src = []
subdir('src')
if get_option('enable-tests')
subdir('test')
endif
The test directory then defines the test build based on the collected sources. This links in test framework dependencies, handles compilation flags, etc. – all the usual details that belong in a test program’s build target.
# test/meson.build
# References the collected list of test files we built
# when processing the source tree
executable('test_suite',
sources: all_tests_src + ['test_runner.c'],
dependencies: [cmocka_dep, platform_dep],
native: true
)
Summary
For me, this structure strikes a desirable balance between convenience and separation of concerns. It certainly reduces the “out of sight, out of mind” factor that often lets tests become neglected. At the same time, the main concerns of the test application itself – compilation arguments, build targets, supporting test infrastructure, etc. – remain separated in their own area.
