What I do
I have two modes of operation that work together:
-
DEDUP — Identify and remove redundant test files/scenarios where another file already covers the same code paths with the same input values. Verified via gcov coverage comparison (before vs. after).
-
GAP-FILL — After dedup, analyze the coverage report to find untested or under-tested code (headers and compiled sources), then write targeted unit tests to fill those gaps. Verified via gcov coverage improvement (before vs. after).
Both modes share a common baseline build and coverage measurement workflow.
When to use me
For DEDUP:
- Your C++ test suite has grown organically with multiple files covering the same ground
- Gap-filler test files (
*_gaps.cpp) were added alongside comprehensive test files (*_comprehensive.cpp) - You inherited a codebase with duplicated test scenarios
- You want to shrink CI test time without losing coverage
For GAP-FILL:
- You have compiled
.cppsource files with no corresponding test file - The coverage report shows headers with <90% line coverage
- You've just removed redundant tests and want to reinvest the saved test time into filling gaps
- You need to increase coverage before a release deadline
Do NOT use me when:
- Tests are parametrized/generative testing the same property (those are intentional redundancy)
- The project has no coverage tooling set up
- You only need to add tests without first checking for redundancy
How I work
Phase 0: Understand the test landscape
Read the test directory and CMakeLists.txt:
test/source/*.cpp
test/CMakeLists.txt
include/<project>/*.hpp
source/*.cpp # compiled sources
Check for:
- Pattern pairs:
*_gaps.cpp+*_comprehensive.cpp,*_basic.cpp+*_detailed.cpp - Compiled
.cppfiles insource/with no correspondingtest_*.cpp - Headers in
include/with no dedicated test file - Test framework (doctest, Catch2, Google Test, etc.) and CMake globbing
Note:
- Test framework and macros used (
TEST_CASE,CHECK_EQ, etc.) - Whether CMakeLists.txt uses
file(GLOB ...)or explicit source lists - The coverage flag pattern (usually
-DENABLE_TEST_COVERAGE=ON)
Phase 1: Detect redundancy at the FILE level
For each candidate file, compare its test scenarios against every other test file in the suite.
Concrete comparison method:
-
Read both files side-by-side. Group test scenarios by what code they exercise:
- Free functions (gcd, lcm, etc.)
- Class methods (constructors, operators, accessors, mutators)
- Template instantiations and specializations
- Edge case coverage (zero, infinity, NaN, boundary values)
-
Check value-level duplication. If both files test the same function with identical values (e.g.,
gcd_recur(12, 8) = 4in both), that scenario is redundant. Minor variations (Fibonacci numbers in one but not the other) are NOT redundant — those are unique inputs. -
Mark a file as "redundant" if >90% of its test scenarios appear in another file with the same or more comprehensive coverage. The remaining <10% unique scenarios (trivial variations) can be safely dropped.
Phase 2: Build baseline (BEFORE removal)
Build the test suite with coverage instrumentation:
| Tool | Configuration |
|---|---|
| GCC + gcov | cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles" |
| Clang + llvm-cov | cmake -S test -B build/cov -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" -G "Ninja" |
| MSVC | Use OpenCppCoverage or VS Enterprise's built-in coverage |
Then build and run:
cmake --build build/cov
./build/cov/<TestExecutable>
Generate coverage for library headers
cd build/cov
# For each library header, run gcov against a test object file that #includes it
gcov -o CMakeFiles/<Tests>.dir/source/<test_file>.cpp.o <path>/include/<project>/<header>.hpp
# For each compiled source, run gcov against the library object file
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<source>.cpp.o <path>/source/<source>.cpp
Record baseline metrics:
Headers:
array_like.hpp: 100% (28/28)
bpqueue.hpp: 100% (73/73)
...
Compiled sources:
ell1d.cpp: 0% (0/23) ← UNTESTED!
ldlt_mgr.cpp: 100% (10/10)
...
Test cases: N | Assertions: M | Status: SUCCESS
Also record the exact uncovered lines for each file (the #####: lines in .gcov output).
Phase 3: Remove the redundant files (DEDUP mode)
Remove-Item -Force test/source/test_*_gaps.cpp # PowerShell
git status test/
git diff --stat
Phase 4: Rebuild and collect AFTER-DEDUP metrics
# Reconfigure (CMake GLOB may detect missing files)
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
Compare coverage against baseline. If any header's coverage drops or uncovered lines change, revert immediately — the file was NOT redundant.
Verdict criteria for DEDUP:
| Check | Requirement |
|---|---|
| All tests pass | Must pass (both before and after) |
| Per-header line coverage | Exactly equal before and after |
| Uncovered lines | Identical set of uncovered lines |
| Test count decreased | Expected — removed test cases |
| Assertion count decreased | Expected — removed assertions |
Phase 5: Analyze coverage gaps (GAP-FILL mode)
If after dedup (or starting fresh) the coverage report shows gaps, identify untested code:
Gap Type A: Compiled source with zero coverage
Check source/*.cpp against test/source/*.cpp. Any .cpp in source/ without a matching test_*.cpp is likely untested.
# Find source files without test coverage
Get-ChildItem source/*.cpp | ForEach-Object {
$base = $_.Name -replace '\.cpp$'
$test = "test/source/test_$base.cpp"
if (-not (Test-Path $test)) { Write-Host "UNTESTED: $($_.Name)" }
}
Gap Type B: Library headers with low coverage
Look at the gcov report for headers with <90% line coverage or "no executable lines". Template-heavy headers may attribute coverage to the .cpp file — check both.
Gap Type C: Functions/branches within covered files
The uncovered lines (#####: in gcov) within partially-covered files reveal specific branches that aren't exercised. These are the highest-value targets.
Phase 6: Write targeted tests
6a: Read the untested source code
Read the source file completely to understand:
- The class/function API (constructors, methods, parameters)
- All conditional branches (if/else, switch cases, early returns)
- Edge cases (null/zero inputs, boundary values, error conditions)
- The mathematical or algorithmic behavior
6b: Design test cases that cover every branch
For each method, enumerate the test scenarios needed:
| Method | Condition | Input | Expected behavior |
|---|---|---|---|
update(cut) | beta > tau | g=1, beta=10 with _r=5 | Returns NoSoln, state unchanged |
update(cut) | beta < -tau | g=1, beta=-10 with _r=5 | Returns NoEffect, state unchanged |
update(cut) | |beta| <= tau, g>0 | g=1, beta=2 with _r=5 | Returns Success, new center at l+_r |
update(cut) | |beta| <= tau, g<0 | g=-1, beta=2 with _r=5 | Returns Success, new center at l+_r |
update_central_cut(cut) | g > 0 | g=1, beta=0 | Halves _r, shifts center left |
update_central_cut(cut) | g < 0 | g=-1, beta=0 | Halves _r, shifts center right |
Include edge cases:
- Zero-width intervals
- Boundary values (
beta == tau,beta == -tau) - Zero gradient (if applicable)
- Repeated/chained operations to verify state transitions
6c: Write the test file
Write a new test_<module>.cpp in test/source/ following the project's existing test patterns:
#define DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS
#include <doctest/doctest.h>
#include <ellalgo/<module>.hpp> // header under test
#include <ellalgo/ell_config.hpp> // if needed for type definitions
#include <utility> // if needed for std::pair
TEST_CASE("<Module>: construction") {
auto x = <Module>(...);
CHECK_EQ(x.some_method(), expected_value);
}
TEST_CASE("<Module>: method with success path") {
auto x = <Module>(...);
auto status = x.update(...);
CHECK_EQ(status, CutStatus::Success);
CHECK_EQ(x.state(), expected_state);
}
TEST_CASE("<Module>: method with failure path") {
auto x = <Module>(...);
auto status = x.update(...); // triggers beta > tau branch
CHECK_EQ(status, CutStatus::NoSoln);
}
TEST_CASE("<Module>: edge cases") {
// Zero-width interval, boundary conditions, etc.
}
TEST_CASE("<Module>: chained operations") {
// Multiple updates in sequence
}
Rules for test values:
- Use hardcoded expected values (not computed from the same code under test)
- Annotate each expected value with the mathematical derivation in a comment (e.g.,
// tau = |5*1| = 5, bound = 5 - 2/1 = 3, _r = half(3-0) = 1.5, _xc = 1.5) - Test each
ifbranch at least once - Test each
switchcase at least once - Test at least one chained/sequential operation to verify state persistence
Phase 7: Rebuild and collect AFTER-GAP-FILL metrics
cmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
cmake --build build/cov
./build/cov/<TestExecutable>
# Regenerate coverage reports
gcov -o CMakeFiles/<Tests>.dir/source/test_<module>.cpp.o <path>/include/<project>/<module>.hpp
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/<module>.cpp.o <path>/source/<module>.cpp
Phase 8: Report coverage improvement
| Metric | Before | After | Δ |
|--------|--------|-------|---|
| Test cases | N | N' | +Δ |
| Assertions | M | M' | +Δ |
| <module>.hpp | XX% | 100% | +Δ |
| <module>.cpp (compiled) | 0% | 100% | +100% |
| All other files | ≥90% | ≥90% | 0% |
Coverage gaps filled: ✅
CRITICAL RULES
Rule 1: Coverage is the truth, not test count
A test file is redundant only if removing it leaves every library header's line coverage unchanged. If coverage drops, the file was covering unique code paths. Revert immediately.
When adding tests, a new test is only successful if it increases coverage of the target file. If coverage of the target file doesn't increase, the test isn't exercising the code — revisit the test logic.
Rule 2: Check uncovered lines, not just percentage
Two runs can show "98.06%" but have different uncovered lines. Always compare the exact set of #####: lines in the .gcov file output:
Get-Content build/cov/foo.hpp.gcov | Select-String -Pattern "^ #####:"
These must be identical before/after dedup. For gap-fill, the #####: lines for the target file must decrease.
Rule 3: Do NOT deduplicate property-based tests
Property-based / generative tests (e.g., RapidCheck) are intentionally redundant by design. They verify invariants with randomized inputs. These should NOT be removed even if they overlap with example-based tests.
Rule 4: Do NOT deduplicate tests with different values
Two tests for the same operator with different input values exercise different code paths. Only remove when input values and expected outputs are identical.
When writing new tests, use DIFFERENT input values from existing tests. Duplicating the same values doesn't increase coverage.
Rule 5: Always rebuild from clean state
.gcda files are consumed by gcov and become stale after any source or binary change. Always rebuild and re-run before measuring:
cmake --build build/cov --clean-first # or rebuild from scratch
./build/cov/<TestExecutable>
Rule 6: Check CMake GLOB behavior
If CMakeLists.txt uses file(GLOB ...) with CONFIGURE_DEPENDS, CMake auto-detects added/removed files on rebuild (CMake 3.12+). On older CMake, reconfigure manually after adding or removing test files.
When writing a new test file, verify that CMake picks it up:
- Look for
test_<module>.cppin the build output - If missing, reconfigure:
cmake -S test -B build/cov
Rule 7: Hardcode expected values, don't recompute
When writing tests, NEVER compute expected values using the same code under test. Always derive them by hand and hardcode them as literals. This catches regressions where the implementation changes but the test still passes because both sides use the same broken code.
Annotate complex derivations:
// tau = |5*1| = 5, bound = 5 - 2/1 = 3
// g>0 so u=3, l=5-5=0
// _r = half_nonnegative(3-0) = 1.5, _xc = 0 + 1.5 = 1.5
CHECK_EQ(e.xc(), 1.5);
Rule 8: Test every branch
For each function with conditional logic, enumerate all branches and write at least one test per branch:
| Branch | Test scenario |
|---|---|
if (beta > tau) | No solution path |
if (beta < -tau) | No effect path |
else (success) | Success path |
g > 0 inside success | Positive gradient |
g < 0 inside success | Negative gradient |
Boundary beta == tau | Success still (≤) |
Boundary beta == -tau | Depends on < vs ≤ |
Rule 9: Prioritize compiled sources over headers
Compiled .cpp files are more likely to have zero coverage because header-only template code is often exercised incidentally by other tests. Prioritize testing:
source/*.cppwithout matchingtest_*.cpp→ highest priority- Headers with
<90%line coverage → medium priority - Uncovered branches within otherwise-covered files → lower priority
Rule 10: Match project test conventions
Before writing tests, study 2-3 existing test files to match:
- Include style (quoted vs angle-bracket paths)
- Test case naming convention
- Assertion macros used (
CHECK_EQvsCHECKvsREQUIRE) #definedirectives (doctest config, etc.)- Comment/documentation style
Common patterns
Pattern A: Gap-filler subsumed by comprehensive
A developer adds test_foo_gaps.cpp to patch coverage holes, then later someone writes test_foo_comprehensive.cpp covering the same gaps plus more.
Detection: Both files in separate commits mentioning "coverage gaps."
Pattern B: Compiled source with no tests
A .cpp file in source/ implements a class but no test_*.cpp exercises it. This is common when new modules are added without corresponding tests.
Detection: Get-ChildItem source/*.cpp vs Get-ChildItem test/source/*.cpp — look for files without a test_ prefix match.
Fix: Write a new test_<module>.cpp covering all public methods with at least one test per branch.
Pattern C: Header with uncovered template branches
A template-heavy header shows ~80-95% coverage because certain template parameter combinations or edge-case branches aren't instantiated by any test.
Detection: #####: lines in the .gcov output reveal the specific branches.
Fix: Add a test that instantiates the template with the missing parameter types or triggers the edge case.
Pattern D: Duplicate test scenarios across files
Two test files independently test the same function with the same values.
Detection: grep for identical CHECK_EQ/CHECK_LT patterns across files.
gcov troubleshooting
Problem: "No executable lines" / "cannot open notes file"
The .gcda files were consumed by a previous gcov run. Re-run the test executable to regenerate them.
./build/cov/<TestExecutable>
Problem: Wrong coverage percentage for a header
You might be running gcov against a .o file that doesn't include the header. Use a test .o file from a source that #includes the target header. For compiled .cpp sources, use the library's .o file:
# Header coverage → use test object file
gcov -o CMakeFiles/<Tests>.dir/source/test_foo.cpp.o include/<project>/foo.hpp
# Compiled source coverage → use library object file
gcov -o _deps/<project>-build/CMakeFiles/<Project>.dir/source/foo.cpp.o source/foo.cpp
Problem: "libgcov profiling error: overwriting an existing profile data with a different checksum"
This happens when source code changed between builds. Delete stale .gcda files and re-run:
Remove-Item -Recurse build/cov/**/*.gcda -Force
./build/cov/<TestExecutable>
Problem: MSVC on Windows
The -DENABLE_TEST_COVERAGE=ON flag uses GCC/Clang-specific flags (-fprofile-arcs -ftest-coverage). On MSVC:
- Install OpenCppCoverage:
scoop install opencppcoverageor download from GitHub - Run:
OpenCppCoverage.exe --sources include\ -- .\build\test\Release\<Tests>.exe - This produces an HTML report with per-line coverage
Alternatively, use GCC via MinGW if available on the system. Set CMAKE_CXX_COMPILER=g++ and CMAKE_MAKE_PROGRAM=make with -G "MinGW Makefiles".