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:
.
├── 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:
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:
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:
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):
While this example uses simple assertions to keep things clear, there are many testing frameworks available that provide more features:
This repository intentionally uses basic assertions rather than a framework to:
Once you understand the concepts, you can easily adopt any framework that suits your needs.
TDD flips the script: write tests BEFORE code.
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.
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.
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:
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:
This is why CI exists - it’s a safety net that never forgets to test.
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
op_xnor
)This simple example demonstrates principles that scale to massive projects:
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++)
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:
Both services are free for open source projects.
Pull requests are welcome! This repository is meant to be educational, so contributions that improve clarity or add examples are especially valued.
BSD 2-Clause License - see LICENSE.txt
© 2016-2025 M. A. Chatterjee <deftio [at] deftio [dot] com>