Test framework eksternal kadang tidak tersedia di target platform—embedded, firmware, atau environment air-gapped. Pada situasi seperti ini kita dapat membangun test harness sendiri: sebuah header kecil berisi macro TEST_ASSERT_*, registry test berupa array TestCase, dan satu fungsi run_tests() yang mencatat keberhasilan dan kegagalan. Membangun harness sendiri juga cara terbaik untuk benar-benar memahami cara kerja framework seperti Unity atau CMocka. Pada chapter ini kita membangun harness dari nol—tanpa library eksternal—dan menggunakannya untuk menguji modul Vec2.

Komponen Utama Test Harness #

Empat komponen yang harus dimiliki setiap harness minimalis:

  1. Penghitung global (g_tests_run, g_tests_failed) yang dipakai bersama semua test.
  2. Macro TEST_ASSERT_* yang mencatat kegagalan dan keluar dari fungsi test dengan return.
  3. Struktur TestCase yang menyimpan pasangan nama string dan pointer fungsi test.
  4. Fungsi run_tests(const TestCase *tests) yang mengiterasi array dan memanggil setiap test sambil melaporkan hasil.
c
/* File: harness.h — test runner sederhana dengan array registry */
#ifndef HARNESS_H
#define HARNESS_H

#include <stdio.h>
#include <string.h>

extern int g_tests_run;
extern int g_tests_failed;

typedef void (*TestFn)(void);

typedef struct {
    const char *name;
    TestFn fn;
} TestCase;

/* Macro TEST_ASSERT_EQUAL_INT — bandingkan dua int */
#define TEST_ASSERT_EQUAL_INT(expected, actual)                                 \
    do {                                                                        \
        int _e = (expected); int _a = (actual);                                 \
        if (_e != _a) {                                                         \
            printf("    FAIL: %s:%d: expected %d, got %d\n",                    \
                   __FILE__, __LINE__, _e, _a);                                \
            g_tests_failed++;                                                   \
            return;                                                             \
        }                                                                       \
    } while (0)

/* Macro TEST_ASSERT_EQUAL_STRING */
#define TEST_ASSERT_EQUAL_STRING(expected, actual)                              \
    do {                                                                        \
        const char *_e = (expected); const char *_a = (actual);                \
        if (_e == NULL || _a == NULL || strcmp(_e, _a) != 0) {                 \
            printf("    FAIL: %s:%d: string mismatch\n", __FILE__, __LINE__);  \
            g_tests_failed++;                                                   \
            return;                                                             \
        }                                                                       \
    } while (0)

/* Macro TEST_ASSERT_TRUE */
#define TEST_ASSERT_TRUE(cond)                                                  \
    do {                                                                        \
        if (!(cond)) {                                                          \
            printf("    FAIL: %s:%d: expected true\n", __FILE__, __LINE__);     \
            g_tests_failed++;                                                   \
            return;                                                             \
        }                                                                       \
    } while (0)

/* Macro untuk definisi test: TEST(test_name) { ... } */
#define TEST(name)  static void name(void)

/* Macro untuk mendaftarkan satu test ke array */
#define MAKE_TEST(name)  { #name, name }

/* Fungsi untuk menjalankan array test yang diakhiri {NULL, NULL} */
static inline void run_tests(const TestCase *tests) {
    for (size_t i = 0; tests[i].fn != NULL; i++) {
        int fail_before = g_tests_failed;
        g_tests_run++;
        printf("[RUN ] %s\n", tests[i].name);
        tests[i].fn();
        if (g_tests_failed == fail_before) {
            printf("[PASS] %s\n", tests[i].name);
        }
    }
}

#endif /* HARNESS_H */
c
/* File: harness.c — definisi penghitung global */
#include "harness.h"

int g_tests_run = 0;
int g_tests_failed = 0;

Modul Produksi yang Diuji #

Modul Vec2 menyimpan vektor 2D dan menyediakan operasi vec_add serta vec_dot. Operasi-operasi ini cukup sederhana untuk diuji menyeluruh, tetapi cukup bermakna untuk memverifikasi bahwa harness kita menangani berbagai tipe data (struct return, scalar return).

c
/* File: vec.h */
#ifndef VEC_H
#define VEC_H
typedef struct { int x; int y; } Vec2;
Vec2 vec_add(Vec2 a, Vec2 b);
int vec_dot(Vec2 a, Vec2 b);
#endif
c
/* File: vec.c */
#include "vec.h"

Vec2 vec_add(Vec2 a, Vec2 b) {
    Vec2 r = { a.x + b.x, a.y + b.y };
    return r;
}

int vec_dot(Vec2 a, Vec2 b) {
    return a.x * b.x + a.y * b.y;
}

Test Runner Menggunakan Harness #

Berikut test runner yang mendaftarkan empat test ke array dan memanggil run_tests. Array diakhiri sentinel {NULL, NULL} sehingga loop berhenti otomatis.

c
/* File: test_vec.c — test runner dengan harness buatan sendiri */
#include <stdio.h>
#include "harness.h"
#include "vec.h"

TEST(test_vec_add_basic) {
    Vec2 a = {1, 2};
    Vec2 b = {3, 4};
    Vec2 r = vec_add(a, b);
    TEST_ASSERT_EQUAL_INT(4, r.x);
    TEST_ASSERT_EQUAL_INT(6, r.y);
}

TEST(test_vec_add_zero) {
    Vec2 a = {5, 7};
    Vec2 zero = {0, 0};
    Vec2 r = vec_add(a, zero);
    TEST_ASSERT_EQUAL_INT(5, r.x);
    TEST_ASSERT_EQUAL_INT(7, r.y);
}

TEST(test_vec_dot) {
    Vec2 a = {1, 2};
    Vec2 b = {3, 4};
    TEST_ASSERT_EQUAL_INT(11, vec_dot(a, b));
}

TEST(test_vec_dot_orthogonal) {
    Vec2 a = {1, 0};
    Vec2 b = {0, 1};
    TEST_ASSERT_EQUAL_INT(0, vec_dot(a, b));
}

int main(void) {
    TestCase tests[] = {
        MAKE_TEST(test_vec_add_basic),
        MAKE_TEST(test_vec_add_zero),
        MAKE_TEST(test_vec_dot),
        MAKE_TEST(test_vec_dot_orthogonal),
        {NULL, NULL}
    };

    run_tests(tests);

    printf("\n=== Hasil ===\n");
    printf("Total: %d, Failed: %d\n", g_tests_run, g_tests_failed);
    return g_tests_failed > 0 ? 1 : 0;
}

Build dan jalankan:

bash
gcc -std=c11 -Wall -Wextra -o test_vec test_vec.c vec.c harness.c && ./test_vec

Output yang Dihasilkan:

text
[RUN ] test_vec_add_basic
[PASS] test_vec_add_basic
[RUN ] test_vec_add_zero
[PASS] test_vec_add_zero
[RUN ] test_vec_dot
[PASS] test_vec_dot
[RUN ] test_vec_dot_orthogonal
[PASS] test_vec_dot_orthogonal

=== Hasil ===
Total: 4, Failed: 0

Mari kita telaah baris-baris kunci di atas:

  1. Baris extern int g_tests_run; (file harness.h): Variabel global dideklarasikan extern di header agar setiap file yang meng-include header dapat mengakses penghitung. Definisi aktual (tanpa extern) hanya ada di harness.c untuk menghindari multiple definition.
  2. Macro TEST(name) (#define TEST(name) static void name(void)): Macro ini mengubah TEST(test_vec_dot) menjadi static void test_vec_dot(void). Hasilnya: test terlihat seperti fungsi biasa, tetapi didaftarkan lewat MAKE_TEST.
  3. Macro MAKE_TEST(name) { #name, name }: #name adalah operator stringifikasi—MAKE_TEST(test_vec_dot) menjadi { "test_vec_dot", test_vec_dot }. Trik ini menghasilkan array TestCase yang ringkas tanpa duplikasi nama.
  4. Macro TEST_ASSERT_EQUAL_INT(expected, actual): Menggunakan do { ... } while (0) agar macro aman dipakai dalam if satu baris. Variabel lokal _e dan _a diawali underscore untuk menghindari benturan dengan variabel test. Bila gagal, macro mencetak pesan dengan __FILE__ dan __LINE__ lalu return—menghentikan test saat ini tanpa menghentikan seluruh runner.
  5. Fungsi run_tests (file harness.h): Loop berhenti ketika tests[i].fn == NULL (sentinel). Sebelum setiap test, fail_before = g_tests_failed disimpan. Setelah test kembali, bila g_tests_failed == fail_before, pesan PASS dicetak. Bila tidak sama, berarti test gagal dan pesan FAIL sudah dicetak oleh macro—kita tidak boleh mencetak PASS.
  6. Baris return g_tests_failed > 0 ? 1 : 0; (file test_vec.c): Kode exit 1 bila ada kegagalan. CI seperti GitHub Actions atau Makefile target test mengandalkan kode exit untuk menentukan keberhasilan build.

Pelaporan Kegagalan #

Saat sebuah test gagal, harness mencatat baris kegagalan dan nama file. Test berikutnya tetap dijalankan—pendekatan continue-on-failure memungkinkan kita melihat semua kegagalan sekaligus, bukan hanya yang pertama.

c
/* File: test_vec_fail.c — demonstrasi test yang gagal */
#include <stdio.h>
#include "harness.h"
#include "vec.h"

TEST(test_vec_dot_wrong) {
    Vec2 a = {1, 2};
    Vec2 b = {3, 4};
    /* vec_dot({1,2},{3,4}) = 3 + 8 = 11, bukan 12 */
    TEST_ASSERT_EQUAL_INT(12, vec_dot(a, b));
}

int main(void) {
    TestCase tests[] = {
        MAKE_TEST(test_vec_dot_wrong),
        {NULL, NULL}
    };
    run_tests(tests);
    printf("\nTotal: %d, Failed: %d\n", g_tests_run, g_tests_failed);
    return g_tests_failed > 0 ? 1 : 0;
}

Output yang Dihasilkan:

text
[RUN ] test_vec_dot_wrong
    FAIL: test_vec_fail.c:10: expected 12, got 11

Total: 1, Failed: 1

Pesan FAIL mencantumkan test_vec_fail.c:10—tepat baris tempat asersi salah berada. Pesan PASS tidak muncul karena g_tests_failed bertambah. Harness mengembalikan kode exit 1 agar Makefile atau CI menandai pipeline sebagai gagal.