facebook

C/C++ Test Automation: A Practical Guide for Development Teams

Most C/C++ teams already have tests. The next step is a strategy that produces two kinds of evidence: results showing that code meets its requirements, and checks showing that it follows the project’s coding standard. Generating both through continuous integration (CI) makes reviews easier and reduces last-minute work before an audit.

You rarely need to replace the toolchain to get there. Start with a consistent unit-test framework, then add static analysis, runtime error detection, and structural coverage. Each layer answers a different question.

This guide covers those layers, including practical MISRA examples, modified condition/decision coverage (MC/DC), and links between requirements, tests, and code. It also explains where safety standards change the evidence your pipeline needs to produce.

A pragmatic testing strategy for C/C++

Adopting every capability at once can stall progress. Use this sequence as a starting point, adjusted to your project’s risks:

  1. Choose the framework and layout. Decide where tests live, how they are named, and how they build.
  2. Create replaceable interfaces. Put hardware access behind a hardware abstraction layer (HAL) or small interfaces so logic can run on a development machine.
  3. Connect CI. Build and run the suite automatically on each change.
  4. Add static analysis. Record existing findings, assign a plan for addressing them, and review new violations.
  5. Measure coverage. Establish a baseline before setting thresholds that reflect the required coverage level.
  6. Add runtime checks. Run sanitizers in a separate diagnostic build to detect memory errors and other runtime defects.
  7. Automate traceability reports. Link requirements to tests and results. Define requirement IDs early, even if reporting comes later.

Each step builds on earlier work, but the order isn’t fixed. Safety-critical projects should plan traceability and target-based evidence from the start.

Choose and structure your unit tests

GoogleTest is a widely used C++ unit-testing framework with an accompanying mocking library. It can also test C code: a C++ test file can include C headers inside an extern “C” block and call the functions directly. Compile the production C sources as C to preserve their language behavior.

The following CMake setup assumes the framework is installed and the project already defines a ring_buffer library. Enable testing at the project level, link the test executable, and register its cases with CTest:

enable_testing()

find_package(GTest CONFIG REQUIRED)

add_executable(ring_buffer_test ring_buffer_test.cpp)

target_link_libraries(ring_buffer_test PRIVATE ring_buffer GTest::gtest_main)

include(GoogleTest)

gtest_discover_tests(ring_buffer_test)

Test discovery registers individual cases without a hand-maintained list. After building, run ctest –output-on-failure from the build directory for both local checks and CI. Cross-compiled test executables need a suitable runner or emulator for discovery and execution.

Keep tests/unit/ aligned with src/, with one test file per module. Use tests/data/ for fixture files, such as captured sensor frames, and tests/fakes/ for replacement hardware interfaces. A reviewer should be able to find the tests for ring_buffer.c without searching.

Use EXPECT_* for independent checks so one failure doesn’t hide later results. Use ASSERT_* when continuing would be unsafe or meaningless, such as after a failed pointer check. Parameterized tests, using TEST_P and INSTANTIATE_TEST_SUITE_P, let one test body cover a table of boundary inputs. Reserve mocks for interactions where call order or arguments matter; a simple fake is often easier to maintain.

Making embedded code testable

Embedded code becomes difficult to test when application logic and register access are tangled together. These patterns help separate them:

  • Replaceable C drivers. Declare spi_write() in a header, provide the real implementation for the target, and link a fake implementation for host tests.
  • Function-pointer tables. Group operations such as read, write, and reset in a structure. Tests supply their own implementations during initialization.
  • Link-time substitution. Select real or fake implementations in the build. This works when the dependencies are separately linked rather than embedded in the code under test.
  • Preprocessor toggles. Use #ifdef UNIT_TEST sparingly. Conditional compilation can make tested behavior differ from production behavior.
  • Compile-time interfaces. In C++, template parameters can select fake peripherals without runtime dispatch.

Unity with CMock is an option for projects that want a C-only test build. CppUTest also supports testing C interfaces, but its test framework requires a C++ compiler. The interface patterns above apply to either approach.

Static analysis and coding-standard enforcement (MISRA and CERT)

Unit tests check behavior for selected inputs. Static analysis examines code without running it and can identify issues beyond those exercised by tests, including unsafe conversions, unreachable code, and unchecked error returns. It doesn’t guarantee that every defect or execution path has been checked.

MISRA C and MISRA C++ provide coding guidelines commonly used in safety-related development. CERT C and C++ emphasize secure coding. Select rules that match your project’s obligations, and record the exact editions used. MISRA C:2025 and MISRA C++:2023 are the latest editions. The examples below use MISRA C:2012 numbering, which many certified projects still reference.

Rule 17.4: every non-void exit path returns a value

MISRA C:2012 Rule 17.4, a mandatory rule, requires an explicit return statement with an expression on each exit path from a non-void function. The first version below has no return for inputs from zero through 100. Using its missing return value can cause undefined behavior:

int32_t clamp_pct(int32_t v) { if (v > 100) { return 100; } if (v < 0) { return 0; } } /* missing return for 0..100 */

int32_t clamp_pct(int32_t v) { if (v > 100) { return 100; } if (v < 0) { return 0; } return v; } /* explicit return on every path */

Note that C99 and later define reaching the end of main as returning zero, so confirm how your checker treats main. Language rules and coding-standard exceptions need to be interpreted together.

Rule 2.2: distinguish dead code from intentional casts

Rule 2.2 prohibits dead code: operations that are executed but can be removed without changing program behavior. Code that can never run is unreachable code, covered separately by Rule 2.1. Rule-set clarifications matter when reviewing explicit casts whose results are used:

uint16_t raw = adc_read(); uint32_t scaled = (uint32_t)raw * 1000u; /* make the operand width explicit before multiplication */

The cast’s effect depends on the target’s integer types and promotion rules. On a platform with 16-bit integers, it can prevent multiplication in an insufficiently wide type. Review the target’s arithmetic behavior and the configured rule interpretation before removing a cast as unnecessary.

Suppressions and deviation records

A suppression hides a finding; a deviation documents why a departure from a rule is acceptable. They aren’t interchangeable. MISRA permits justified deviations from required rules, but not from mandatory rules.

Where a deviation is allowed, record the rule, location, justification, reviewer, and supporting evidence. Link any suppression to that record, and revisit it when the affected code or assumptions change.

Teams enforcing MISRA or CERT rules can use Parasoft C/C++test to bring static analysis, unit testing, code coverage and requirements traceability into an existing CI workflow. Record the tool version and analysis configuration with the build, and confirm support for the standard editions named in your plan. 

Structural coverage that stands up to audits

Coverage measures which parts of the code tests exercised. Common measures answer different questions:

  • Statement coverage: which executable statements ran?
  • Decision coverage: did each decision produce every possible outcome? Tools may distinguish this from compiler-level branch coverage.
  • Condition coverage: did each Boolean condition evaluate both true and false?
  • MC/DC: did each condition independently affect its decision’s outcome, according to the applicable coverage definition?

Decision and condition coverage aren’t interchangeable: meeting one doesn’t necessarily meet the other. DO-178C includes MC/DC objectives for Level A software, while ISO 26262 lists MC/DC as highly recommended for ASIL D. Use your verification plan to establish the required measures and acceptable evidence. 

Host coverage is useful engineering feedback, but it doesn’t automatically represent the target build. Compiler settings, data types, hardware behavior, and conditional compilation can differ. Agree early on the target environment or justified alternative used for verification evidence.

For a basic coverage workflow, instrument the code under test as well as the test executable. GCC commonly uses –coverage; Clang uses -fprofile-instr-generate -fcoverage-mapping. Run the tests, then generate reports with compatible coverage tools. HTML helps reviewers inspect missed code; CI-readable output may require an additional report converter.

For projects needing dedicated coverage reporting, commercial tools can collect statement, decision and MC/DC coverage alongside an existing GoogleTest suite, and connect that evidence to requirements traceability. Check integration details and qualification support against the project’s verification plan.

Add runtime bug detectors early

Functional tests can pass while reading beyond a buffer. AddressSanitizer (ASan) can detect out-of-bounds accesses, use-after-free, and related memory errors on the paths your tests exercise. Enable it during compilation and linking, using a separate diagnostic build because instrumentation adds overhead.

For a supported compiler and host platform, a CMake configuration for both C and C++ sources could look like this:

cmake -S . -B build-asan -DCMAKE_C_FLAGS=”-fsanitize=address -fno-omit-frame-pointer -g” -DCMAKE_CXX_FLAGS=”-fsanitize=address -fno-omit-frame-pointer -g” -DCMAKE_EXE_LINKER_FLAGS=”-fsanitize=address”

Build and run the tests from build-asan. Confirm that the relevant libraries are instrumented too, rather than applying the flags only to the test harness.

Where supported, add UndefinedBehaviorSanitizer (UBSan) for issues such as signed overflow and misaligned access. Use a separate ThreadSanitizer (TSan) configuration for data races; it can’t generally be combined with ASan. Constrained embedded targets may not support these tools, making host-testable logic especially useful.

Traceability: map requirements to tests to code

Traceability answers a practical review question: which tests verify this requirement, and did they run against this build?

  • Tag tests with requirement IDs. Include an ID in the test name, such as TEST(Speed_SRS_042, RejectsNegativeInput), or attach it with RecordProperty(“requirement”, “SRS-042”).
  • Export machine-readable results. Use –gtest_output=xml: to produce test-result XML. Confirm that your CI parser retains custom requirement properties.
  • Collect evidence per build. Store test results, coverage, and static analysis findings with the source revision and build configuration.

In regulated projects, aim for reports that work in both directions: from each requirement to its tests and results, and from each test back to its purpose. Requirements without verification evidence need review. Unlinked tests may provide useful robustness or diagnostic checks, but their purpose should be documented.

Coverage adds another view: which code did those tests reach? A requirement can have passing tests while related logic remains untested. That mismatch deserves investigation rather than an automatic “verified” label.

CI/CD reference workflow

A practical pipeline separates fast feedback from evidence collection while retaining both:

  1. Build the production configuration and the host-test configurations.
  2. Run static analysis against the relevant production settings, reporting new findings and unresolved baseline issues.
  3. Run unit tests with ctest, using automatic discovery where the execution environment supports it.
  4. Run sanitizer tests in their diagnostic configurations.
  5. Apply quality gates. Require passing tests, review new rule violations, and check coverage against agreed thresholds and changed-code expectations.
  6. Publish evidence with the build identifier, including reports from failed runs.

The following is a conceptual job map, not a complete pipeline configuration:

jobs: [build, static-analysis, unit-tests, sanitizers]

gate: [static-analysis, unit-tests, sanitizers]

publish: always # retain available results even when a job fails

Run independent jobs in parallel where resources allow. Show fast unit-test results as soon as they’re ready, but keep required sanitizer checks in the merge or release gate. Publishing only after success hides the reports needed to diagnose failures.

What good looks like by safety context

The appropriate evidence depends on the product’s risks and verification plan. These examples are starting points, not complete compliance checklists.

General embedded

Host-based statement and branch coverage, sanitizer checks, and selected coding-standard rules provide useful feedback. Add target tests for hardware interactions, timing, and behavior the host can’t reproduce. Document exceptions and unresolved findings.

ISO 26262 ASIL C and D

Start with requirements-based tests, then use coverage to identify verification gaps. For ASIL D, plan for the highly recommended MC/DC measure or justify an alternative as required by the safety process. Document the execution environment, rule-set editions, deviation decisions, and tool-confidence assessment.

DO-178C Level B versus Level A

Level B includes decision-coverage objectives; Level A adds MC/DC. Both require more than coverage percentages. Plan requirements-based verification, structural coverage analysis, and the independence required for applicable objectives. Tool output supports this process but doesn’t replace it.

Tooling options

Compare tools against compiler support, target constraints, report formats, and qualification needs. Common choices include:

LayerOpen source or built inCommercial
Unit testsGoogleTest; CppUTest; Unity/CMock for C-only test buildsVectorCAST, Cantata, TESSY
Coverage and evidencegcov/lcov and llvm-cov; available measures depend on tool and versionLDRA; coverage and traceability capabilities vary by product
Static analysisCompiler warnings and clang-tidy for selected checksKlocwork, Coverity, PVS-Studio, Polyspace
Runtime detectionASan, UBSan, TSan on supported platformsEvaluate separately if the sanitizer toolchain doesn't meet project needs

Open-source tools can provide substantial testing and coverage support. Commercial products may add integrated reporting, deviation workflows, target support, or qualification materials. Evaluate the specific product and configuration rather than assuming that a tool category guarantees compliance.

Walk-through: reaching MC/DC on a three-condition decision

Consider an interlock that permits motion when the door is closed and speed is zero, or when an override is active:

bool motion_allowed(bool door_closed, bool speed_zero, bool override) { return (door_closed && speed_zero) || override; }

Checking only the overall true and false outcomes doesn’t show that every condition matters. For this expression, the following four cases demonstrate independent effects at the Boolean-input level:

Casedoor_closedspeed_zerooverrideResultShows independence of
1TTFTBaseline
2FTFFdoor_closed, compared with case 1
3TFFFspeed_zero, compared with case 1
4FT
T
T
override, compared with case 2

Each comparison changes one input and flips the result while the other input values stay fixed. Implement the cases as a parameterized test, using descriptive names so reviewers can identify each independence pair. Four cases work for this expression; don’t assume every decision with three conditions needs exactly four.

Short-circuit evaluation needs attention. The code skips speed_zero in cases 2 and 4, and skips override in case 1. Confirm how your coverage tool and accepted MC/DC definition treat conditions that aren’t evaluated.

MC/DC doesn’t prove that allowing an override is safe or correct. Expected results must come from the requirement, not from running the implementation and copying its output.

Common pitfalls

  • Testing implementation details. Friend classes and #define private public can tightly couple tests to internals. Prefer public behavior and replaceable dependencies.
  • Mixing hardware failures with unit-test failures. Keep board-dependent tests in a clearly labeled stage with controlled setup and useful diagnostics.
  • Relying on line coverage alone. Fully covered lines can still contain untested conditions or decision outcomes.
  • Writing tests only to raise coverage. Each test needs a meaningful expected result. Executing code isn’t the same as verifying it. The same applies to AI-generated tests: each one still needs an assertion a reviewer can trace back to a requirement. 
  • Leaving suppressions unmanaged. Every suppression needs a reviewable reason, an owner, and a link to the relevant record.

Checklist and next steps

Use this checklist to identify the next gap in your pipeline:

  1. A documented unit-test framework and directory layout.
  2. Automatic test discovery or an equivalent maintained registration process.
  3. CI execution on each change, with failing required tests blocking merges.
  4. Replaceable hardware interfaces for host tests.
  5. Static analysis with versioned rules, a baseline, and a plan for unresolved findings.
  6. Reviewed deviations linked to any associated suppressions.
  7. Separate sanitizer configurations included in the appropriate quality gates.
  8. Coverage reporting at the level required by the verification plan.
  9. Requirement IDs preserved in exported results.
  10. A release evidence package tied to the source revision and build configuration.

For an existing project, begin with repeatable builds, reliable tests, and automatic results. Add analysis and runtime checks around that foundation. Plan traceability early, then expand reporting as the suite matures. The goal isn’t the largest tool stack; it’s trustworthy evidence of what was checked, against which requirements, and in which build.



Sudeep Bhatnagar
Co-founder & Director of Business
Sudeep Bhatnagar

Talk to our experts who have been running successful Digital Product Development (Apps, Web Apps), Offshore Team Operations, and Hardcore Software Development Campaigns. During the discovery session, we'll explore the opportunities and Scope of the work and provide you an expert consulting on the right options to achieve the outcomes.

Be it a new App Development project, or creation of an offshore developers team, or digitalization of your existing market offerings - You'll get the best advise and service and pricing. We are excited to speak to you!

Book a Call

Let’s Create Big Stories Together!

Mobile is in our nerves. We don’t just build apps, we create brands.

Choosing us will be your best decision.

Relevant Blog Posts