Skip to Content
Course content

197: Unit Testing C Code with a Minimal Framework

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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:

  1. Create a minimal testing framework using an ASSERT_EQ macro.
  2. 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 pop returns 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.
  3. In main(), run the suite and print the total number of passes and failures.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.