Project 11: Testing and Analysis Framework

A lightweight C testing framework with integrated static and dynamic analysis tools.

Quick Reference

Attribute Value
Difficulty Level 3 - Advanced
Time Estimate 1-2 weeks
Main Programming Language C
Alternative Programming Languages Python (for test runners)
Coolness Level Level 3 - Genuinely Clever
Business Potential Level 3 - Service & Support
Prerequisites C basics, build tools, debugging basics
Key Topics Unit testing, sanitizers, static analysis

1. Learning Objectives

By completing this project, you will:

  1. Build a minimal test framework with assertions and fixtures.
  2. Integrate sanitizers (ASan/UBSan) into the build.
  3. Run static analysis and enforce clean code policies.
  4. Design deterministic tests for C libraries.
  5. Produce CI-ready test commands and reports.

2. All Theory Needed (Per-Concept Breakdown)

Concept 1: Test Harness Design and Deterministic Testing

Fundamentals

A test harness is a structured way to define, run, and report tests. In C, there is no built-in test framework, so you must design one. A good harness provides assertions, setup/teardown, and clear output. Deterministic tests produce the same results every time, which is essential for debugging and CI. This requires control over randomness, time, and external dependencies.

Deep Dive into the concept

A test framework needs to solve three problems: how to define tests, how to run them, and how to report results. The simplest approach is a TEST(name) macro that registers a function pointer in a table. The harness iterates over the table, runs each test, and counts pass/fail. Assertions should report file and line, and they should stop the current test without crashing the whole suite. This can be implemented with return codes or setjmp/longjmp for non-local exits.

Deterministic testing is especially important in systems programming. If a test depends on time, random numbers, or external files, it can produce flaky results. The fix is to inject dependencies: pass a fake clock or random seed, use temporary files with known contents, and avoid global state. For example, if a test needs randomness, seed a PRNG with a fixed value. If a test needs time, stub it with a fixed timestamp. This is not about “making tests pass”; it’s about making failures reproducible.

Another key aspect is isolation. Tests should not affect each other. This means each test should allocate and free its own resources, and shared state should be reset between tests. Fixtures provide a consistent setup/teardown pattern. In C, you can implement fixtures by providing setup() and teardown() callbacks that run before and after each test.

Finally, test output must be actionable. A good harness prints the failing assertion, expected vs actual, and a test summary. For CI, you may want to emit a machine-readable format like JUnit XML. This project will build a basic harness with text output and optionally a JSON report, demonstrating how to keep testing lightweight but effective.

In larger systems, deterministic testing also means isolating external dependencies. For example, if your code reads files, you can inject a fake filesystem or use temporary directories with known contents. If your code depends on environment variables, tests should set and reset them explicitly. Another technique is golden files: store expected outputs in files and compare them byte-for-byte, which makes regressions easy to detect. For concurrency-related code, determinism is harder; you can simulate concurrency with single-threaded event loops or use controlled scheduling. Even if you do not implement concurrency here, documenting the limitation and the strategy is valuable. These practices elevate the test harness from a simple runner to a professional-quality testing system.

To operationalize this concept in a real codebase, create a short checklist of invariants and a set of micro-experiments. Start with a minimal, deterministic test that isolates one rule or behavior, then vary a single parameter at a time (inputs, flags, platform, or data layout) and record the outcome. Keep a table of assumptions and validate them with assertions or static checks so violations are caught early. Whenever the concept touches the compiler or OS, capture tool output such as assembly, warnings, or system call traces and attach it to your lab notes. Finally, define explicit failure modes: what does a violation look like at runtime, and how would you detect it in logs or tests? This turns abstract theory into repeatable engineering practice and makes results comparable across machines and compiler versions.

How this fits on projects

Definitions & key terms

  • Test harness: Framework to define/run/report tests.
  • Assertion: A check that signals failure if false.
  • Fixture: Setup/teardown logic for tests.
  • Determinism: Tests produce the same results every run.

Mental model diagram (ASCII)

[Test Table] -> run -> assert -> report -> summary

How it works (step-by-step, with invariants and failure modes)

  1. Register tests in a table.
  2. For each test, run setup, then test, then teardown.
  3. Record pass/fail and print summary.

Invariant: Tests are isolated and reproducible. Failure mode: Shared state causes flaky tests.

Minimal concrete example

#define ASSERT_EQ(a,b) do { if ((a)!=(b)) return 1; } while (0)

Common misconceptions

  • “Tests should be fast so they can skip cleanup.” → Leaks break isolation.
  • “Randomized tests are fine.” → Without seeding, they are not reproducible.
  • “Assertions should abort the whole suite.” → They should only fail the test.

Check-your-understanding questions

  1. What is the role of a fixture?
  2. Why is determinism important?
  3. How do you stop a test without killing the process?
  4. Why isolate tests from each other?
  5. What should a test report include?

Check-your-understanding answers

  1. Setup/teardown logic to provide consistent state.
  2. It makes failures reproducible and debuggable.
  3. Return codes or longjmp to exit the test.
  4. To prevent state leakage and flaky results.
  5. Test name, failure location, expected/actual, summary counts.

Real-world applications

  • CI pipelines for C libraries.
  • Regression testing for system components.

Where you’ll apply it

References

  • “Practical Unit Testing in C” (various open-source frameworks)
  • Unity/CMock frameworks

Key insights

A lightweight test harness can deliver high reliability if it is deterministic.

Summary

Testing in C requires building your own harness and enforcing determinism. A disciplined approach yields reproducible tests and reliable software.

Homework/Exercises to practice the concept

  1. Implement a basic ASSERT_EQ macro.
  2. Build a test table and iterate it.
  3. Write a deterministic test for a random function.

Solutions to the homework/exercises

  1. Macro compares values and returns error.
  2. Use a static array of function pointers.
  3. Inject a fixed seed or stub the RNG.

Concept 2: Static and Dynamic Analysis (Sanitizers, Valgrind, Linters)

Fundamentals

Static analysis inspects code without running it, looking for potential bugs. Dynamic analysis runs the program under instrumentation to detect errors like invalid memory access. Tools like AddressSanitizer (ASan), UndefinedBehaviorSanitizer (UBSan), and Valgrind are essential for professional C development. Integrating these tools into your build and test process catches bugs early.

Deep Dive into the concept

Static analyzers such as clang-tidy or cppcheck scan code for suspicious patterns: uninitialized variables, dead code, mismatched types, and common API misuse. They are not perfect, but they provide a safety net that catches many classes of bugs before runtime. Static analysis is most effective when configured with project-specific rules and run regularly.

Dynamic analyzers instrument the program. ASan adds red zones around allocations and checks memory access, catching out-of-bounds reads/writes and use-after-free. UBSan detects undefined behavior like signed overflow, null pointer dereference, and invalid shifts. Valgrind’s memcheck tool tracks memory accesses and finds leaks and invalid writes, though it can be slower. These tools are extremely valuable when building low-level C code because many bugs are silent until they corrupt data.

Integrating these tools into your build system requires separate build configurations. For example, you might build with -fsanitize=address,undefined for sanitizers and with -O0 -g for debuggability. You should also provide environment variables like ASAN_OPTIONS to control behavior (e.g., abort on first error). The test framework should support running tests under sanitizer builds and collecting the results.

Another aspect is minimizing false positives. Some UB in C code is intentional (e.g., type punning via unions), and sanitizers might flag it. Your framework should allow suppressions or documentation for such cases. The key is to treat analysis tools as first-class citizens: run them automatically, fail the build on severe issues, and use their reports to improve code quality.

To operationalize this concept in a real codebase, create a short checklist of invariants and a set of micro-experiments. Start with a minimal, deterministic test that isolates one rule or behavior, then vary a single parameter at a time (inputs, flags, platform, or data layout) and record the outcome. Keep a table of assumptions and validate them with assertions or static checks so violations are caught early. Whenever the concept touches the compiler or OS, capture tool output such as assembly, warnings, or system call traces and attach it to your lab notes. Finally, define explicit failure modes: what does a violation look like at runtime, and how would you detect it in logs or tests? This turns abstract theory into repeatable engineering practice and makes results comparable across machines and compiler versions.

Another way to deepen understanding is to map the concept to a small decision table: list inputs, expected outcomes, and the assumptions that must hold. Create at least one negative test that violates an assumption and observe the failure mode, then document how you would detect it in production. Add a short trade-off note: what you gain by following the rule and what you pay in complexity or performance. Where possible, instrument the implementation with debug-only checks so violations are caught early without affecting release builds. If the concept admits multiple approaches, implement two and compare them; the act of measuring and documenting the difference is part of professional practice. This habit turns theoretical understanding into an engineering decision framework you can reuse across projects.

How this fits on projects

Definitions & key terms

  • Static analysis: Analyzing code without executing it.
  • Dynamic analysis: Running code under instrumentation to detect errors.
  • Sanitizers: Compiler-based runtime checks for memory/UB.
  • Valgrind: Dynamic analysis framework for memory debugging.

Mental model diagram (ASCII)

code -> build (sanitizer) -> run tests -> report
code -> static analyzer -> report

How it works (step-by-step, with invariants and failure modes)

  1. Build with sanitizer flags.
  2. Run tests; sanitizer checks memory and UB.
  3. Collect reports and fail on errors.

Invariant: Sanitizer builds must pass all tests. Failure mode: Ignoring sanitizer output allows latent bugs.

Minimal concrete example

clang -fsanitize=address,undefined -g -O0 test.c -o test
./test

Common misconceptions

  • “Sanitizers slow down too much to use.” → Use them in CI, not release.
  • “Valgrind replaces sanitizers.” → They catch different classes of bugs.
  • “Static analysis is optional.” → It finds bugs before runtime.

Check-your-understanding questions

  1. What does ASan detect?
  2. When would you use UBSan?
  3. Why are sanitizer builds separate from release builds?
  4. How does Valgrind differ from ASan?
  5. Why run static analysis on every commit?

Check-your-understanding answers

  1. Out-of-bounds, use-after-free, heap corruption.
  2. For undefined behavior like overflow, invalid shifts.
  3. They add overhead and change optimization.
  4. Valgrind is slower but can catch leaks without recompiling.
  5. It catches issues early and enforces code quality.

Real-world applications

  • Security audits of C libraries.
  • CI pipelines for production systems.

Where you’ll apply it

References

  • Clang sanitizer documentation
  • Valgrind user manual

Key insights

Analysis tools are your safety net in a language without runtime protection.

Summary

Static and dynamic analysis tools detect bugs that are otherwise invisible. Integrating them into your test framework is essential for professional C development.

Homework/Exercises to practice the concept

  1. Run ASan on a program with a buffer overflow.
  2. Use cppcheck on a small C project.
  3. Compare UBSan output for signed overflow.

Solutions to the homework/exercises

  1. ASan should report the exact line of overflow.
  2. cppcheck should warn about uninitialized variables.
  3. UBSan reports overflow with file/line details.

3. Project Specification

3.1 What You Will Build

A small C testing framework with assertions, fixtures, and reporting, plus integration scripts for sanitizers and static analysis tools.

3.2 Functional Requirements

  1. Test registration: Macro-based test definition and registration.
  2. Assertions: At least 5 assertion macros.
  3. Fixtures: Optional setup/teardown per test.
  4. Reporting: Summary of pass/fail counts.
  5. Analysis integration: Build targets for ASan/UBSan and static analysis.

3.3 Non-Functional Requirements

  • Performance: Suite runs under 5 seconds.
  • Reliability: Deterministic outputs for tests.
  • Usability: Clear CLI options for running subsets.

3.4 Example Usage / Output

$ ./test_runner --filter string
[PASS] test_strlen
[FAIL] test_overflow (line 42)
Summary: 9 passed, 1 failed

3.5 Data Formats / Schemas / Protocols

JSON report (optional):

{ "passed": 9, "failed": 1, "tests": [ ... ] }

3.6 Edge Cases

  • Tests that crash or abort.
  • Non-deterministic tests.
  • Missing dependencies for analysis tools.

3.7 Real World Outcome

What you will see:

  1. A lightweight test runner.
  2. Automated sanitizer builds.
  3. Static analysis reports integrated into CI.

3.7.1 How to Run (Copy/Paste)

make test
make test-asan
make lint

3.7.2 Golden Path Demo (Deterministic)

Run a fixed test suite and verify pass/fail counts.

3.7.3 If CLI: exact terminal transcript

$ ./test_runner
[PASS] test_init
[PASS] test_append
Summary: 2 passed, 0 failed
Exit: 0

Failure demo (deterministic):

$ ./test_runner --filter missing
ERROR: no tests matched filter
Exit: 2

4. Solution Architecture

4.1 High-Level Design

+-------------------+
| test registry      |
+---------+---------+
          |
          v
+-------------------+     +-------------------+
| runner             | -->| reports           |
+-------------------+     +-------------------+

4.2 Key Components

| Component | Responsibility | Key Decisions | |———–|—————-|—————-| | Registry | Store tests | Static table | | Runner | Execute tests | Filter support | | Reporter | Summaries | Text + JSON |

4.3 Data Structures (No Full Code)

typedef int (*test_fn)(void);

4.4 Algorithm Overview

  1. Register tests in a static array.
  2. Filter by name if requested.
  3. Run tests and report results.

Complexity Analysis:

  • Time: O(T)
  • Space: O(T)

5. Implementation Guide

5.1 Development Environment Setup

clang -std=c23 -Wall -Wextra -Werror -g

5.2 Project Structure

ctest/
├── src/
│   ├── runner.c
│   ├── assert.c
│   └── main.c
├── include/
│   └── ctest.h
├── tests/
└── Makefile

5.3 The Core Question You’re Answering

“How do I build a reliable testing pipeline for C code without heavy dependencies?”

5.4 Concepts You Must Understand First

  1. Deterministic testing principles.
  2. Assertion and fixture design.
  3. Static and dynamic analysis tools.

5.5 Questions to Guide Your Design

  1. How will you register tests without a runtime registry?
  2. How will you handle failures without aborting the suite?
  3. What outputs will CI consume?

5.6 Thinking Exercise

Design an assertion macro that reports file and line.

5.7 The Interview Questions They’ll Ask

  1. Why are deterministic tests important?
  2. What does ASan detect?
  3. How would you test a function that uses randomness?

5.8 Hints in Layers

  • Hint 1: Start with a single test and assertion.
  • Hint 2: Add fixtures and filtering.
  • Hint 3: Add sanitizer build targets.

5.9 Books That Will Help

| Topic | Book | Chapter | |——-|——|———| | Testing | “The Practice of Programming” — Kernighan | Ch. 6 |

5.10 Implementation Phases

Phase 1: Foundation (3-4 days)

  • Implement test registry and runner.
  • Checkpoint: Tests run and report.

Phase 2: Core Functionality (4-5 days)

  • Add assertions and fixtures.
  • Checkpoint: Failure reports include file/line.

Phase 3: Polish & Edge Cases (2-3 days)

  • Add JSON output and analysis integration.
  • Checkpoint: Sanitizer builds pass.

5.11 Key Implementation Decisions

| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Registration | manual list, macro | macro | Less boilerplate | | Reporting | text, JSON | text + JSON | CI + human readability |


6. Testing Strategy

6.1 Test Categories

| Category | Purpose | Examples | |———|———|———-| | Unit tests | Validate framework | assertion tests | | Integration tests | Full suite | make test | | Analysis tests | Sanitizer runs | make test-asan |

6.2 Critical Test Cases

  1. Assertion failure reports correct line.
  2. Fixture runs before/after each test.
  3. Filter option selects subset.

6.3 Test Data

Expected: 2 passed, 0 failed

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

| Pitfall | Symptom | Solution | |——–|———|———-| | Global state shared | Flaky tests | Reset state in fixtures | | Non-determinism | Inconsistent results | Fix seeds and inputs | | Ignoring sanitizer output | Hidden bugs | Fail build on warnings |

7.2 Debugging Strategies

  • Run with ASAN_OPTIONS=halt_on_error=1.
  • Use --filter to isolate failing tests.

7.3 Performance Traps

Excessive logging slows tests; keep output minimal by default.


8. Extensions & Challenges

8.1 Beginner Extensions

  • Add colorized output.

8.2 Intermediate Extensions

  • Add a test discovery system via linker sections.

8.3 Advanced Extensions

  • Add coverage reporting with gcov/llvm-cov.

9. Real-World Connections

9.1 Industry Applications

  • CI pipelines for C libraries and embedded firmware.
  • Static analysis enforcement in safety-critical projects.
  • Unity test framework.
  • Catch2 (in C++, similar patterns).

9.3 Interview Relevance

  • Testing strategies and sanitizer usage are common interview topics.

10. Resources

10.1 Essential Reading

  • Sanitizer documentation (Clang/GCC)
  • “The Practice of Programming” testing chapter

10.2 Video Resources

  • Talks on C testing and static analysis

10.3 Tools & Documentation

  • ASan, UBSan, Valgrind, cppcheck

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain deterministic testing.
  • I can integrate sanitizers into builds.
  • I can interpret static analysis output.

11.2 Implementation

  • Tests run and report correctly.
  • Sanitizer builds pass.
  • Reports are reproducible.

11.3 Growth

  • I can design a CI pipeline for C projects.
  • I can debug memory bugs using tools.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Test harness with assertions and runner.
  • Sanitizer build targets.
  • Static analysis integration.

Full Completion:

  • All minimum criteria plus:
  • JSON or JUnit report output.

Excellence (Going Above & Beyond):

  • Test discovery via linker sections and coverage reports.