C - Building a Unit Testing Framework in C
Unit testing is a software testing technique where individual functions or small sections of code are tested independently to ensure they work correctly. In C programming, there are many popular unit testing frameworks such as Unity, CUnit, and Check. However, understanding how these frameworks work internally helps programmers write better tests and improve the quality of their applications. Building a simple unit testing framework from scratch is an excellent way to learn how automated testing is implemented in C.
What Is a Unit Test?
A unit test is a small program that verifies whether a specific function behaves as expected. Instead of testing the entire application, each function is tested separately with different inputs and expected outputs.
For example, consider a function that adds two numbers.
int add(int a, int b)
{
return a + b;
}
A unit test checks whether the function returns the correct result.
if(add(5, 3) == 8)
printf("Test Passed\n");
else
printf("Test Failed\n");
Although this works, writing such code repeatedly becomes difficult as the number of functions increases. A unit testing framework automates this process.
Why Build a Unit Testing Framework?
A testing framework provides a structured way to execute multiple tests and display results.
Benefits include:
-
Reduces manual testing effort.
-
Detects bugs early.
-
Makes debugging easier.
-
Improves software quality.
-
Enables automated regression testing.
-
Makes projects easier to maintain.
Instead of writing custom test logic every time, the framework provides reusable testing functions.
Components of a Simple Unit Testing Framework
A basic testing framework usually contains the following components:
Test Runner
The test runner executes all registered test cases one after another.
Example:
int main()
{
test_add();
test_subtract();
test_multiply();
return 0;
}
The runner controls the execution order of all tests.
Assertion Functions
Assertions compare the actual result with the expected result.
Example:
void assertEqual(int expected, int actual)
{
if(expected == actual)
printf("PASS\n");
else
printf("FAIL\n");
}
Instead of manually checking every result, the assertion function performs the comparison.
Writing the First Assertion
Suppose there is a multiplication function.
int multiply(int a, int b)
{
return a * b;
}
Its test becomes
void testMultiply()
{
assertEqual(20, multiply(4,5));
}
The framework checks whether the returned value matches the expected answer.
Improving the Assertion Function
Displaying only PASS or FAIL is not sufficient. It is better to display expected and actual values.
void assertEqual(int expected, int actual)
{
if(expected == actual)
{
printf("PASS\n");
}
else
{
printf("FAIL\n");
printf("Expected = %d\n", expected);
printf("Actual = %d\n", actual);
}
}
This makes debugging much easier.
Counting Test Results
Most testing frameworks count how many tests passed and failed.
Example:
int passed = 0;
int failed = 0;
Modify the assertion function.
void assertEqual(int expected, int actual)
{
if(expected == actual)
{
passed++;
}
else
{
failed++;
}
}
After executing all tests,
printf("Passed : %d\n", passed);
printf("Failed : %d\n", failed);
This provides a summary of the testing session.
Testing Multiple Cases
A function should not be tested with only one input.
Example:
void testAddition()
{
assertEqual(4, add(2,2));
assertEqual(10, add(6,4));
assertEqual(0, add(-5,5));
assertEqual(-4, add(-2,-2));
}
Testing multiple cases increases confidence that the function works correctly under different conditions.
Creating Test Functions
Each function in the application should have its own test function.
Example:
void testAdd()
{
assertEqual(8, add(5,3));
}
void testSubtract()
{
assertEqual(2, subtract(5,3));
}
void testDivide()
{
assertEqual(5, divide(10,2));
}
The test runner executes each of these independently.
Using Macros for Simplicity
Macros reduce repetitive code.
Example:
#define ASSERT(expected, actual) \
assertEqual(expected, actual)
Now the test becomes
ASSERT(8, add(5,3));
This makes test cases shorter and easier to read.
Testing String Functions
Assertions are not limited to integers.
Example:
#include <string.h>
void assertString(char expected[], char actual[])
{
if(strcmp(expected, actual) == 0)
printf("PASS\n");
else
printf("FAIL\n");
}
Example test
assertString("Hello", getMessage());
The framework now supports string comparisons.
Testing Floating-Point Numbers
Floating-point numbers should not be compared directly because of precision errors.
Instead,
#include <math.h>
void assertFloat(float expected, float actual)
{
if(fabs(expected - actual) < 0.001)
printf("PASS\n");
else
printf("FAIL\n");
}
This allows small differences while still verifying correctness.
Organizing Test Files
Large projects separate production code and test code.
Example directory structure:
Project/
src/
math.c
math.h
tests/
test_math.c
test_runner.c
This organization keeps testing code independent of application code.
Running All Tests Automatically
The test runner calls every test.
Example:
int main()
{
testAdd();
testSubtract();
testMultiply();
testDivide();
printf("Passed : %d\n", passed);
printf("Failed : %d\n", failed);
return 0;
}
One execution verifies the entire module.
Handling Failed Tests Gracefully
A failed test should not stop the remaining tests.
Instead,
if(expected != actual)
{
failed++;
}
else
{
passed++;
}
The framework continues executing all tests and reports every failure at the end.
Testing Edge Cases
Good testing includes unusual inputs.
Examples:
-
Zero values
-
Negative numbers
-
Maximum integer values
-
Minimum integer values
-
Overflow conditions
-
Division by zero
-
Empty strings
-
NULL pointers
Example:
assertEqual(0, add(0,0));
assertEqual(-10, add(-5,-5));
Testing edge cases improves software reliability.
Adding Test Names
Displaying the name of each test makes reports easier to understand.
Example:
printf("Running testAdd...\n");
Output:
Running testAdd...
PASS
Running testSubtract...
PASS
Running testMultiply...
FAIL
Developers can immediately identify which test failed.
Advantages of Building Your Own Testing Framework
Creating a simple testing framework helps programmers understand the internal mechanics of professional testing tools. It demonstrates how assertions compare expected and actual values, how test runners automate execution, and how summaries are generated. This knowledge also encourages better software design by promoting modular code that is easier to test, debug, and maintain.
Best Practices
-
Write tests for every important function.
-
Test both valid and invalid inputs.
-
Keep each test independent.
-
Use meaningful test names.
-
Separate testing code from application code.
-
Include edge cases and boundary conditions.
-
Execute tests whenever code changes.
-
Keep assertions simple and readable.
-
Maintain a clear summary of passed and failed tests.
-
Expand the framework gradually to support additional data types and more advanced testing features.
Conclusion
Building a unit testing framework in C provides valuable insight into how automated testing works behind the scenes. By implementing assertion functions, organizing test cases, creating a test runner, and generating clear reports, developers can verify that individual functions perform as expected. Although professional frameworks offer many advanced features, developing a basic framework from scratch strengthens programming skills, improves debugging techniques, and encourages the creation of reliable, maintainable C applications.