C
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions
-
Section 4: Arrays and Strings
-
Section 5: Pointers
-
Section 6: Memory Management
-
Section 7: Structures and Unions
-
Section 8: The Preprocessor and Build Process
-
Section 9: Standard Library: stdio.h
-
Section 10: Standard Library: stdlib.h
-
Section 11: Standard Library: string.h
-
Section 12: Standard Library: ctype.h and wctype.h
-
Section 13: Standard Library: math.h, complex.h, fenv.h, tgmath.h
-
Section 14: Standard Library: Type and Limit Headers
-
Section 15: Standard Library: Error Handling and Debugging
-
Section 16: Standard Library: Localization and Encoding
-
Section 17: Standard Library: time.h
-
Section 18: Standard Library: Concurrency (C11)
-
Section 19: POSIX and System Programming (unistd.h)
-
Section 20: More Data Structures
-
Section 21: Algorithms in C
-
Section 22: Bitwise Operations
-
Section 23: Command-Line Programs
-
Section 24: Debugging and Best Practices
-
Section 25: Compiler and Language Internals
-
Section 26: Embedded and Cross-Platform Considerations
-
Section 27: Networking Basics
-
Section 28: Practical Projects
-
Section 29: Interview Practice
-
Section 30: C23 Modern Features
-
Section 31: More Practice and Review
197: Unit Testing C Code with a Minimal Framework
I've spent more time than I'd like to admit chasing bugs that only appear when a string is empty or contains nothing but whitespace. It's a classic C rite of passage. You write a function, test it with "Hello World", it works, and you ship it—only for it to crash the moment a user enters three spaces and hits enter.
char* trim_whitespace(char* str) {
char* end = str + strlen(str) - 1;
// Trim leading space
while(*str == ' ') {
str++;
}
// Trim trailing space
while(*end == ' ') {
*end = '\0';
end--;
}
return str;
}
The "All-Whitespace" Crash
At first glance, the logic above seems sound. We move the start pointer forward and the end pointer backward. But try passing it a string like " ". The first while loop will push str past the end of the actual data. Then, the second while loop starts decrementing end. Since str has already moved past end, the pointers cross, and we start writing null terminators into memory we don't own. It's a textbook buffer underflow.
Preventing Pointer Crossover
The fix is simple: we need to ensure that our leading pointer never overtakes our trailing pointer. I usually handle this by checking the boundaries before entering the second loop.
char* trim_whitespace(char* str) {
if (str == NULL) return NULL;
char* start = str;
char* end = str + strlen(str) - 1;
while(*start == ' ' && start <= end) {
start++;
}
// If the string was all spaces, start is now > end
while(*end == ' ' && end >= start) {
*end = '\0';
end--;
}
return start;
}
This works because start <= end acts as a guardrail. But here is the problem: how do we know this actually fixed it? And more importantly, how do we know we didn't break the "Hello World" case while fixing the "all spaces" case? You could manually run the program ten times with different inputs, but that's a waste of your life. This is where a minimal testing framework comes in.
Building a Lightweight Test Runner
You don't need a massive library like Google Test or Check for most C projects. In fact, when I'm starting a new utility library, I usually just write a few macros. The goal is to avoid printf debugging and instead get a clear "PASS" or "FAIL" for a dozen different scenarios in milliseconds.
int tests_run = 0;
int tests_failed = 0;
#define ASSERT_EQ(actual, expected, test_name) do { \
tests_run++; \
if ((actual) != (expected)) { \
printf("[FAIL] %s: Expected %s, got %s\n", test_name, expected, actual); \
tests_failed++; \
} else { \
printf("[PASS] %s\n", test_name); \
} \
} while (0)
I use the do { ... } while (0) pattern here because it's a C idiom that ensures the macro behaves like a single statement, preventing weird bugs when you use the macro inside an if statement without curly braces.
Organizing Tests into Suites
Once you have a basic assertion macro, you can group your tests into a dedicated function. This keeps your main() clean and allows you to run specific suites of tests depending on what you're working on.
void test_trim_whitespace() {
ASSERT_EQ(trim_whitespace(" hello "), "hello", "Trim both sides");
ASSERT_EQ(trim_whitespace(" "), "", "Trim all spaces");
ASSERT_EQ(trim_whitespace("world"), "world", "No spaces to trim");
ASSERT_EQ(trim_whitespace(" leading"), "leading", "Trim leading only");
ASSERT_EQ(trim_whitespace("trailing "), "trailing", "Trim trailing only");
}
int main() {
test_trim_whitespace();
printf("\nResult: %d/%d tests passed.\n", tests_run - tests_failed, tests_run);
return tests_failed > 0;
}
By structuring it this way, you've created a safety net. If you decide to optimize trim_whitespace later—perhaps to handle tabs or newlines—you can just run your test suite. If you see a [FAIL], you know exactly which edge case you broke before you even hit the compiler's gcc command again.
📋 Practical Task
Implementing a Test Suite for a Circular Buffer
You are tasked with writing a unit test suite for a CircularBuffer implementation. The buffer is designed to store integers and overwrite the oldest data when it becomes full.
The Code to Test:
typedef struct {
int *buffer;
int head;
int tail;
int max;
int is_full;
} CircularBuffer;
void cb_init(CircularBuffer *cb, int size) {
cb->buffer = malloc(size * sizeof(int));
cb->max = size;
cb->head = 0;
cb->tail = 0;
cb->is_full = 0;
}
void cb_push(CircularBuffer *cb, int data) {
cb->buffer[cb->head] = data;
if (cb->is_full) {
cb->tail = (cb->tail + 1) % cb->max;
}
cb->head = (cb->head + 1) % cb->max;
cb->is_full = (cb->head == cb->tail);
}
int cb_pop(CircularBuffer *cb) {
if (!cb->is_full && (cb->head == cb->tail)) return -1; // Empty
int data = cb->buffer[cb->tail];
cb->is_full = 0;
cb->tail = (cb->tail + 1) % cb->max;
return data;
}
Your Assignment:
- Create a minimal testing framework using an
ASSERT_EQmacro. - Write a test function
test_circular_buffer()that verifies the following scenarios:- Basic Push/Pop: Push one item and pop it back.
- FIFO Order: Push three items and pop them in the correct order.
- Overflow: Push more items than the buffer size (e.g., push 5 items into a buffer of size 3) and verify that the
popreturns the most recent 3 items in correct order (the oldest 2 should be overwritten). - Empty State: Attempt to pop from an empty buffer and verify it returns
-1.
- In
main(), run the suite and print the total number of passes and failures.
There are no comments for now.