CommunityCoding & Developmentgithub.com

luk036/dedup-cpp-tests

Find and remove redundant C++ unit tests, then fill coverage gaps by analyzing gcov reports and adding targeted tests — all verified with before/after coverage comparison

What is dedup-cpp-tests?

dedup-cpp-tests is a OpenCode agent skill that find and remove redundant C++ unit tests, then fill coverage gaps by analyzing gcov reports and adding targeted tests — all verified with before/after coverage comparison.

Works with~Claude Code~Codex CLI~Cursor✓OpenCode
npx skills add https://github.com/luk036/agent-skills/tree/HEAD/skills/dedup-cpp-tests

Installed? Explore more Coding & Development skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

What I do

I have two modes of operation that work together:

  1. 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).

  2. 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 .cpp source 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 .cpp files in source/ with no corresponding test_*.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:

  1. 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)
  2. Check value-level duplication. If both files test the same function with identical values (e.g., gcd_recur(12, 8) = 4 in both), that scenario is redundant. Minor variations (Fibonacci numbers in one but not the other) are NOT redundant — those are unique inputs.

  3. 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:

ToolConfiguration
GCC + gcovcmake -S test -B build/cov -DENABLE_TEST_COVERAGE=ON -DCMAKE_CXX_COMPILER=g++ -G "Unix Makefiles"
Clang + llvm-covcmake -S test -B build/cov -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-fprofile-instr-generate -fcoverage-mapping" -G "Ninja"
MSVCUse 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:

CheckRequirement
All tests passMust pass (both before and after)
Per-header line coverageExactly equal before and after
Uncovered linesIdentical set of uncovered lines
Test count decreasedExpected — removed test cases
Assertion count decreasedExpected — 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:

MethodConditionInputExpected behavior
update(cut)beta > taug=1, beta=10 with _r=5Returns NoSoln, state unchanged
update(cut)beta < -taug=1, beta=-10 with _r=5Returns NoEffect, state unchanged
update(cut)|beta| <= tau, g>0g=1, beta=2 with _r=5Returns Success, new center at l+_r
update(cut)|beta| <= tau, g<0g=-1, beta=2 with _r=5Returns Success, new center at l+_r
update_central_cut(cut)g > 0g=1, beta=0Halves _r, shifts center left
update_central_cut(cut)g < 0g=-1, beta=0Halves _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 if branch at least once
  • Test each switch case 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>.cpp in 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:

BranchTest scenario
if (beta > tau)No solution path
if (beta < -tau)No effect path
else (success)Success path
g > 0 inside successPositive gradient
g < 0 inside successNegative gradient
Boundary beta == tauSuccess still (≤)
Boundary beta == -tauDepends 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:

  1. source/*.cpp without matching test_*.cpp → highest priority
  2. Headers with <90% line coverage → medium priority
  3. 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_EQ vs CHECK vs REQUIRE)
  • #define directives (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:

  1. Install OpenCppCoverage: scoop install opencppcoverage or download from GitHub
  2. Run: OpenCppCoverage.exe --sources include\ -- .\build\test\Release\<Tests>.exe
  3. 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".

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

Related Skills