Practicalities of Co-locating Tests with Source Code

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:

  1. Mirrored Trees
  2. Co-location
    1. Build System Structure
  3. Summary

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 in test/, 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.

Share Your Thoughts

This site uses Akismet to reduce spam. Learn how your comment data is processed.