Continuous Integration (CI) is a powerful practice for keeping software projects organized and reliable, especially when working in C. By automating tasks like building, testing, and memory checking, CI helps catch issues early and keeps codebases consistent. In this post, we’ll walk through how to set up a CI pipeline for a C program. Along the way, we’ll cover practical tips for writing testable code, creating workflows with GitHub Actions, and using tools like Valgrind and Leak to catch memory issues before they become problems. Let’s dive in!
Table of Content
- Write Modular, Testable Code
- Unit Testing
- Integrate Testing into the Build System
- Configure a CI Pipeline
- Conclusion
Write Modular, Testable Code
Modular code makes your program easier to maintain and test by separating functionality into reusable components. This section will explain how to organize code into libraries, define clear APIs, and write code that’s easier to verify and debug.
Below is an header file, heap.h, for a heap library. The heap interface defines a generalized heap structure with customizable comparison and cleanup functions. Here’s an overview of the API:
#ifndef HEAP_H
#define HEAP_H
typedef struct Heap_ {
int size;
int (*compare)(const void *key1, const void *key2);
void (*destroy)(void *data);
void **tree;
} Heap;
void heap_init(Heap *heap, int (*compare)(const void *key1, const void *key2), void (*destroy)(void *data));
void heap_destroy(Heap *heap);
int heap_insert(Heap *heap, const void *data);
int heap_extract(Heap *heap, void **data);
#define heap_size(heap) ((heap)->size)
#endif
Note: The Heap structure uses void pointers (void *) to store and manage any type of data. This provides flexibility and enables users to work with heterogeneous data while ensuring type safety through the comparison and destruction function pointers.
For a deeper dive into how void pointers work in C and their versatility, you can refer to the earlier blog post: Understanding the void Keyword in C.
Unit Testing
The C language lacks a built-in testing framework. Third-party frameworks can be used; however, in this section, we’ll walk through using assertions to validate the functionality of our heap library and then cover how GitHub Actions can be used to automatically call test runners to streamline the testing process.
Unit testing tests a small portion of the code base, therefore, to test the entire code base, a lot of unit tests are needed. Typically, unit tests are added when new code is added. A test runner’s purpose is execute all the units tests. If a unit test fails then the test runner stops.
GitHub Actions can be configured to automatically execute a test runner whenever an action occurs to a repo. In our case, we want a test runner to execute every time a Pull Request is made. When a test runner fails to complete it will be reported by GitHub and the developer that submitted the code change must investigate the cause of the failure. This process ensures that all changes are verified before they are merged into the main branch and forms part of the continuous integration (CI) and continuous development (CD) process.
I’ll keep unit testing simple by using a combination of assert() and if blocks to test that a portion of the code is functioning the same as it was before, that is, a new code does not introduce side effects that impact existing code functionality.
Assertions are an easy way to catch errors early by ensuring that certain conditions hold true during the execution of your program. In C, we can use the <assert.h> library to add simple checks throughout the code.
Here’s a breakdown of how assertions are used in our example test code:
Validating Heap Insertions
To begin, we assert that inserting an element into the heap is successful. The heap_insert() function returns 0 when an insertion is successful. By adding an assertion, we can immediately catch any issues with the heap insertion:
assert(heap_insert(&thePostOffice, &parcels[idx]) == 0);
This line checks that the heap_insert function call returns 0. If it doesn’t, the program will stop and report an error. This error will also be caught by CI pipeline that we will build later.
Ensuring Correct Heap Order on Extraction
One of the key properties of a heap is that it must maintain its ordering when extracting elements. In this case, the heap is sorted by the importance of the parcels. To validate this, we extract a parcel from the heap and assert that the importance value of the newly extracted parcel is greater than or equal to the previous one.
assert(previous <= data->importance); // Ensure heap sorting.
This assertion ensures that after each extraction, the heap’s property is preserved. If the condition fails, the program will halt, and we can investigate why the heap is not maintaining its order.
Verifying Heap Size After Extractions
Finally, it’s important to confirm that the heap is empty after all elements have been extracted. This assertion ensures that the heap size is zero after all extractions are complete:
assert(heap_size(&thePostOffice) == 0); // Ensure heap is empty
If the heap is not empty after all extractions, this assertion will catch the problem early.
Here is the full test code:
// ---- test.c ----
#include "heap.h"
#include <stdlib.h>
#include <assert.h>
#include <time.h>
#include <limits.h>
typedef struct Parcel_ {
int importance;
int weight;
} Parcel;
static int compare(const void *p1, const void *p2) {
Parcel *parcel1, *parcel2;
parcel1 = (Parcel *)p1;
parcel2 = (Parcel *)p2;
if (parcel1->importance < parcel2->importance) return -1;
else if (parcel1->importance > parcel2->importance) return 1;
else return 0;
}
static void destroy(void *tree) {
return;
}
// Function to generate random importance
int GenRandImport( void ) {
srand((int)time(NULL));
int max = 999999;
int min = 100000;
int range = max - min;
return (rand() % range) + min;
}
int main(void) {
int idx;
Heap thePostOffice;
heap_init(&thePostOffice, compare, destroy);
Parcel parcels[8];
for (idx = 0; idx < 8; idx++) {
parcels[idx].importance = GenRandImport();
parcels[idx].weight = idx * 10;
assert(0 == heap_insert(&thePostOffice, &parcels[idx]));
}
int previous = INT_MIN;
for (idx = 0; idx < 8; idx++) {
Parcel *data;
assert(0 == heap_extract(&thePostOffice, (void**)&data));
assert(previous <= data->importance); // Ensure heap sorting.
previous = data->importance;
}
// Ensure the heap is empty at the end
assert(heap_size(&thePostOffice) == 0);
heap_destroy(&thePostOffice);
return 0;
}
Integrate Testing into the Build System
Our build system will use:
- clang: to compile c source code files,
- Make: used to define a set of build tasks to be executed, and
# makefile for test heap runner
CC=clang
LIBS=-lc++
FILES=heap.c test.c
OBJECTS=test.o heap.o
all:
${CC} ${FILES} -o testHeap
debug:
${CC} -g ${FILES} -o testHeap
clean:
rm -f ${OBJECTS} testHeap testHeap.dSYM
For the purpose of this blog only the all: instruction is needed (and is the default action). To run make and build testHeap, type make in a terminal:
:
make
The debug: and the clean: commands are used to add debug information to the executable and the clean instruction is used to remove files not needed in source control.
For more information about make and its capabilities, refer to the official GNU Make documentation: GNU Make Manual
We want to now setup a process where the test.c code can be compiled and then execute. A key fundamental is that the CI pipeline will
Configure a CI Pipeline
In this section, we’ll walk through a sample workflow file and briefly explain how it works.
GitHub Actions uses workflow files written in YAML. These files must be saved in the .github/workflows/ directory of your repository. Each workflow file describes a sequence of jobs and steps that GitHub will execute automatically. Here is an example YAML file to build our test runner:
name: CI Workflow for Heap Library
on:
push:
branches:
- main
paths:
- heap/**
pull_request:
branches:
- main
paths:
- heap/**
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y build-essential clang valgrind
- name: Build project
working-directory: ./heap
run: make all
- name: Run tests
working-directory: ./heap
run: ./testHeap
- name: Memory checks with Valgrind
working-directory: ./heap
run: valgrind --leak-check=full --error-exitcode=1 ./testHeap
If you are unfamiliar with YAML syntax or GitHub workflows, you can find detailed documentation about workflows: GitHub Actions documentation page.
Here are a some key points about our yaml file:
The source code for this blog was saved in a repo under the directory heap. Therefore, this workflow only runs a job when a change occurs for a file in the heap directory.
paths:
- heap/**
This code snippet show how our unit tests are executed.
- name: Run tests
working-directory: ./heap
run: ./testHeap
Note: GitHub workflow is marked as failed when a non-zero value is returned. This is how valgrind can be used to check for memory leaks.
- name: Memory checks with Valgrind
working-directory: ./heap
run: valgrind --leak-check=full --error-exitcode=1 ./testHeap
Conclusion
In this blog, we explored how to implement a robust testing and Continuous Integration (CI) pipeline for a C program. We began by emphasizing the importance of writing modular and testable code, which lays the foundation for reliable software development. Then, we demonstrated the use of assert to validate key functionalities of our heap library, ensuring early error detection during runtime.
We integrated these tests into the build system using Make and explained how to automate the testing process with GitHub Actions. By configuring a CI pipeline, we showcased how automated workflows can streamline the development process, improve code quality, and catch issues early by running tests on every code change.
By adopting these practices, developers can ensure that their code is reliable, maintainable, and always ready for production. Whether you’re working on a personal project or collaborating with a team, CI tools like GitHub Actions make testing and integration efficient and straightforward.
I hope this guide inspires you to implement CI pipelines in your own projects and continue improving your development workflow. Happy coding!