A quick overview of testing C/C++ code, covering basic unit tests to continuous integration. In this repo we'll go through concepts such as unit testing and coverage to CI with a simple working example.
This repository shows testing concepts using a simple C library. We'll explore:
- Unit testing basics
- Code coverage and what it tells us
- Test-driven development concepts
- Continuous integration with GitHub Actions
- How these pieces fit together in practice
.
βββ lib.h # Library header with function declarations
βββ lib.c # Library implementation (math & bitwise operations)
βββ test-library.c # Test suite with assertions
βββ makefile # Build rules with coverage flags
βββ CMakeLists.txt # CMake build configuration (alternative to make)
βββ run_coverage_test.sh # Script to generate coverage reports
βββ .github/
β βββ workflows/
β βββ ci.yml # GitHub Actions CI configuration
βββ codecov.yml # Coverage service configuration
βββ .gitignore # Git ignore patterns
When we write a function, we usually run it a few times manually to check it works. Testing just formalizes this process. Instead of manual checks, we write code that does the checking for us.
Testing helps us understand:
- Does it work? - Does the code do what we intended?
- Does it handle edge cases? - What about negative numbers? Zero? Maximum values? (or even more complex inputs)
- Will it keep working? - When we change other code, did we break this?
- Is it completely tested? - Did we miss any scenarios?
Let's look at a basic test. In this repo we have a small math operations library. Here's one of our library functions:
// From lib.c
int op_add(int x, int y) {
int r = x + y;
return r;
}
To test this, we write a function that calls our function in dedicated test program:
// From test-library.c
if (op_add(2, 3) != 5) {
printf("TEST FAILED: op_add(2, 3) should equal 5\n");
return 1; // Exit with error
}
printf("TEST PASSED: op_add works correctly\n");
This is a test! It's simple but powerful:
- It calls the function with known inputs
- It checks if the output is correct
- It reports success or failure
Now let's look at a slightly more complex example:
int add5ifGreaterThan2(int a) {
int r;
if (a > 2)
r = a + 5; // Path 1: When a > 2
else
r = a; // Path 2: When a <= 2
return r;
}
This function has two execution paths. If we only test with a = 10
, we only test Path 1. We've missed half the function!
To test completely, we need to test every path:
// Test Path 1: When a > 2
assert(add5ifGreaterThan2(3) == 8); // 3 + 5 = 8 β
assert(add5ifGreaterThan2(10) == 15); // 10 + 5 = 15 β
// Test Path 2: When a <= 2
assert(add5ifGreaterThan2(1) == 1); // Returns 1 unchanged β
assert(add5ifGreaterThan2(2) == 2); // Boundary: exactly 2 β
// Test edge cases
assert(add5ifGreaterThan2(0) == 0); // Zero β
assert(add5ifGreaterThan2(-5) == -5); // Negative β
Key Insight: Every if
statement creates paths. The same is true for switch statements. Every path needs tests.
But how do we know we've tested all paths? That's where code coverage comes in. We can use a tool to find what paths have been taken in our tests and which ones have not been tested.
Code coverage is like a GPS tracker for your tests - it shows you exactly which lines of code were executed during testing. Let's see it in action:
# Run tests with coverage tracking
make clean
make
./test-library.out
gcov lib.c
cat lib.c.gcov
The coverage report shows:
2: 9:int op_and(int x, int y) {
2: 10: return x & y;
-: 11:}
-: 12:
3: 17:int op_xor(int a, int b){
3: 18: int r = a ^ b;
3: 19: return r;
-: 20:}
-: 21:
#####: 22:int op_xnor(int a, int b){
#####: 23: return ~(a ^ b);
-: 24:}
What do these symbols mean?
2:
- This line ran 2 times β3:
- This line ran 3 times β#####:
- This line NEVER ran!β οΈ -:
- Non-executable line (comments, brackets)
The smoking gun: Lines 22-23 (op_xnor
function) were never tested! Our test suite has a gap.
From this report, we calculate:
- Lines of code: 10 executable lines
- Lines tested: 8 lines executed
- Coverage: 8/10 = 80%
To achieve 100% coverage, we need to add:
assert(op_xnor(0x0F, 0xF0) == ~(0x0F ^ 0xF0)); // Test the missing function
Warning: 100% coverage β bug-free code!
Consider this function with 100% line coverage:
int divide(int a, int b) {
return a / b; // 100% covered if we test divide(10, 2)
}
But what about divide(10, 0)
? π₯ Division by zero!
Coverage tells you what you tested, not what you missed. As Dijkstra famously said: "Testing shows the presence, not the absence of bugs."
Research shows (Namin & Andrews, 2009):
- < 50% coverage: Many bugs remain
- 70-80% coverage: Good balance of effort vs benefit
- > 90% coverage: Diminishing returns
While this example uses simple assertions to keep things clear, there are many testing frameworks available that provide more features:
- Google Test - Feature-rich, widely used in industry
- Catch2 - Header-only, simple to integrate
- Unity - Minimal framework, perfect for embedded systems
- CppUTest - Designed specifically for embedded development
- Check - Unit testing framework for C
- CUnit - Lightweight C testing framework
- Boost.Test - Part of the Boost libraries
This repository intentionally uses basic assertions rather than a framework to:
- Focus on testing concepts rather than framework syntax
- Keep the example accessible to embedded developers
- Show that testing doesn't require complex tools
- Minimize dependencies
Once you understand the concepts, you can easily adopt any framework that suits your needs.
TDD flips the script: write tests BEFORE code.
- Red π΄ - Write a failing test
- Green π’ - Write minimal code to pass
- Refactor π - Clean up while tests stay green
Example:
// Step 1: Write test first (RED - fails because function doesn't exist)
assert(op_multiply(3, 4) == 12);
// Step 2: Write minimal code (GREEN - just enough to pass)
int op_multiply(int a, int b) {
return a * b;
}
// Step 3: Refactor if needed (keep it GREEN)
Now imagine you're working with a team. How do you ensure everyone's code is tested? Enter CI/CD.
- Developer writes code
- Developer remembers to run tests (maybe)
- Developer commits code
- Other developers pull broken code
- π± Everything breaks
But we can do better. In fact we can force the tests to be run every time code is checkedin to the repo. This is what CI (continuosu integration) is about.
- Developer writes code
- Developer commits code
- CI automatically runs all tests
- If tests fail, the commit is rejected
- β Main branch stays clean
With CI you can have some assurance that the test suites are being run and even how much coverage there is with each check-in.
Look at the badges at the top of this README:
- Green CI badge: All tests passing, safe to use
- Red CI badge: Tests failing, something's broken
- Coverage badges: Show test coverage percentage
These update automatically with every commit!
What happens when CI detects a failure?
# From .github/workflows/ci.yml
- name: Run tests
run: |
./test-library.out
# If this fails, the build stops here!
If tests fail:
- CI stops immediately β
- Badge turns red π΄
- GitHub can block the merge
- Team gets notified
- No broken code reaches production
This is why CI exists - it's a safety net that never forgets to test.
- CI (Continuous Integration): Automatically test every change
- CD (Continuous Deployment): If tests pass, automatically deploy
The full pipeline:
Code β Test β Build β Deploy
β
CI ensures this never fails
If CI fails, deployment stops. This prevents broken code from reaching users.
# Clone this repository
git clone https://github.com/deftio/C-and-Cpp-Tests-with-CI-CD-Example.git
cd C-and-Cpp-Tests-with-CI-CD-Example
# Build and run tests
make
./test-library.out
# Check coverage
./run_coverage_test.sh
cat lib.c.gcov # Shows which lines were tested
- Run coverage and see if any functions are missing tests
- Add a test for any untested functions (hint: check
op_xnor
) - Modify a function to break its test, then fix it
- Fork the repo and watch GitHub Actions run your tests automatically
This simple example demonstrates principles that scale to massive projects:
- Linux Kernel: ~30 million lines, extensive test suites
- Chrome Browser: Thousands of tests run on every commit
- Embedded Systems: Safety-critical code with 100% coverage requirements
The task of writing tests, check coverage, automate with CI are the same ones used by professional developers worldwide.
Beyond CI and CD are many other types of tests, such as integration tests which show how well code connects together, system and endurance tests which test how robust code is to certain types of errors or whether it can run a long time. Often small memory leaks are not caught early on because it takes a long time to for enough memory to be lost to make the system unstable. Knowing your domain well is key to avoiding many classes of errors.
Q: How much testing is enough? A: Generally, when you feel confident making changes without breaking things.
Q: Should I test simple/obvious code? A: It's often worth it - simple code can have surprising bugs.
Q: What if code is hard to test? A: This often suggests the code could be structured better.
The code in this repo is written in C (but build tools can also handle C++)
- Compiler: GCC or Clang (C99+)
- Tools: Make and/or CMake
- Coverage: gcov (included with GCC)
make clean # Clean build artifacts
make # Build project
make test # Run tests
make coverage # Generate coverage report
mkdir build && cd build
cmake ..
make
make test
make coverage
Ubuntu/Debian:
sudo apt-get install gcc make cmake lcov
macOS:
brew install gcc cmake lcov
Windows: Use WSL or MinGW
To get coverage badges working:
- Visit codecov.io
- Sign in with GitHub
- Add your repository
- (Optional) Add CODECOV_TOKEN to GitHub secrets
- Visit coveralls.io
- Sign in with GitHub
- Enable your repository
Both services are free for open source projects.
- Unit Testing - Wikipedia
- Continuous Integration - Martin Fowler
- Google Test Primer - Google
Pull requests are welcome! This repository is meant to be educational, so contributions that improve clarity or add examples are especially valued.
- 1.0.4 (2025) - Added CMake, enhanced documentation, focus on testing narrative
- 1.0.3 (2024) - Added GitHub Actions
- 1.0.2 (2021) - Travis CI updates
- 1.0.0 (2016) - Initial release
BSD 2-Clause License - see LICENSE.txt
Β© 2016-2025 M. A. Chatterjee <deftio [at] deftio [dot] com>