Course outline

C / Intermediate: Memory and Reliable Components / Lesson 12

Testing, Diagnostics, and Debugging

Combine executable checks with contract reasoning, and investigate the first point where a program violates its invariant.

About 5 min readContent reviewed
On this page

Lesson goals

  • Choose boundary and failure-path tests from contracts.
  • Use assertions appropriately without hiding required work.
  • Interpret debugger and sanitizer evidence within its limits.

Derive tests from promises

A test compares observed behavior with a stated expectation. Derive cases from input domains, boundaries, invariants, and error policy rather than from the lines that happen to exist in the implementation. For a search, cover an empty sequence, first and last positions, an absent target, and repeated targets under the chosen first-match rule.

Keep tests deterministic and small enough that a failed expectation explains a property. A passing suite samples executions; it does not prove the absence of undefined behavior or every possible defect. Combine tests with reviews that establish valid ranges, initialized data, ownership, and complete cleanup.

Make a small test self-checking

The first example checks several properties of a maximum function and returns failure immediately if a property is violated. Its test oracle is independent and simple: known pairs have known maxima. Comparing with zero and including equal operands exercise the decision boundary without requiring a testing framework.

A production project may use a framework for reports and fixtures, but the useful idea is still explicit expected behavior. Do not merely duplicate the implementation’s expression in every expected result, because both copies can repeat the same misunderstanding. For arithmetic helpers, add extreme values once the contract establishes those operations are safe.

c A tiny deterministic test runner
#include <stdio.h>

static int maximum(int a, int b)
{
    return a >= b ? a : b;
}

int main(void)
{
    if (maximum(2, 5) != 5) return 1;
    if (maximum(-3, 0) != 0) return 1;
    if (maximum(4, 4) != 4) return 1;
    puts("3 checks passed");
    return 0;
}
Expected output
3 checks passed

Each expected result is a known value; success is printed only after all checks pass.

Assertions document internal facts

assert expresses an internal condition that the program expects to hold. When NDEBUG is defined, the assertion expression is not evaluated, so it must not perform required work. Never put allocation, input, increment, or essential state updates inside an assertion. Use ordinary error handling for invalid external input and resource failures.

The second example computes the result first, then asserts a mathematical property. A separate explicit comparison still gives the demonstration a meaningful failure result when assertions are disabled. In larger software, assertions can expose violated internal invariants early, while the public interface continues to implement its documented recoverable errors.

c An assertion without hidden work
#include <assert.h>
#include <stdio.h>

int main(void)
{
    int a = 7;
    int b = 4;
    int result = a >= b ? a : b;
    assert(result >= a && result >= b);
    if (result != 7) return 1;
    puts("invariant holds");
    return 0;
}
Expected output
invariant holds

Computing result does not depend on whether assert is enabled.

Stop near the broken invariant

Build with debug information, reproduce the failure with recorded synthetic input, and use a debugger to stop before the suspicious operation. A backtrace shows the call path; inspecting local values and bounds can reveal why an assumption failed. A watchpoint can help find the write that unexpectedly changes a particular object.

The crash location may be far from the original invalid write. Find the earliest incorrect state rather than adding a null check wherever the crash happens. Optimized builds can rearrange or eliminate source-level objects, so compare an appropriate debug build while retaining a regression test for the release configuration.

Use instruments as additional witnesses

AddressSanitizer instruments supported builds to diagnose many bounds and lifetime errors. UndefinedBehaviorSanitizer checks selected undefined operations such as some invalid shifts and signed overflow. GCC-compatible commands commonly use -g -O1 -fsanitize=address,undefined, with the same sanitizer flags at linking. These are tool-specific options and require a compatible runtime.

A clean instrumented run covers only executed paths and supported checks. Some defects require different instruments, and concurrency analysis commonly uses a separate ThreadSanitizer build rather than combining it with AddressSanitizer. Preserve the minimized input that triggered each defect, repair the violated contract, and rerun the regression plus relevant nearby boundaries.

Common errors and fixes

Release builds skip an essential function call.

Why it happens
The call exists only inside assert and NDEBUG disables it.
How to fix it
Execute required work outside assert and check its result explicitly.
Prevent it next time
Review every assertion for side effects.

A fix silences the crash but corrupts results.

Why it happens
The symptom was patched without restoring the invariant.
How to fix it
Trace the earliest invalid state and repair its cause.
Prevent it next time
Add a regression that checks the correct result as well as survival.

A clean sanitizer run is treated as proof.

Why it happens
Unexecuted paths and unsupported checks were ignored.
How to fix it
Combine instrumentation with contract review and targeted cases.
Prevent it next time
Record each tool’s coverage and limits.

Check your understanding

1. Choose tests for a checked buffer append that leaves the buffer unchanged when full. State what each test verifies.

Show worked solution

Test an empty buffer with spare capacity, one slot before full, exactly full, and invalid input under the declared contract. Verify both returned status and length/content after each call. For the full case, retain a copy of the previous meaningful elements and compare them afterward. This checks the unchanged-on-failure promise rather than only the success flag.

2. A program crashes during free. Give a diagnosis plan that does not assume free itself is defective.

Show worked solution

Confirm the pointer is the current owner returned by allocation, not an interior address or a previously released block. Inspect preceding writes for out-of-bounds access and ownership copies for double release. Reproduce with debug information and an address detector in an isolated build. Repair the first invalid operation and retain the triggering synthetic input as a regression.

Key takeaways

  • Tests need an independent expectation derived from a contract.
  • Assertions must not carry required side effects.
  • Debuggers and sanitizers provide evidence; invariants explain the repair.

Official references