← Lập trình C nâng cao

Bài 17 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Unit test trong C

Biên soạn bởi Nguyễn Anh Tuấn

Kiểm một module C cô lập: Arrange · Act · Assert; phủ happy/edge/error; table-driven; framework tí hon → CTest → Unity.

Bạn vừa học cách dựng module (Stack, vec) và đóng gói thành thư viện. Câu hỏi tiếp theo của một senior: làm sao biết module chạy ĐÚNG - và vẫn đúng sau khi sửa? Câu trả lời là unit test: chương trình nhỏ gọi module với dữ liệu biết trước rồi khẳng định kết quả. Mỗi test theo khuôn Arrange · Act · Assert.

  • Unit = MỘT đơn vị (hàm/module) kiểm CÔ LẬP - nhanh, xác định, lặp được.
  • Arrange (dựng cảnh) → Act (gọi đúng một hành vi) → Assert (khẳng định kết quả).
  • Mỗi test kiểm một hành vi: khi đỏ, biết ngay hỏng ở đâu.

Giá trị lớn nhất của test là chống hồi quy (regression): bạn sửa code, bộ test chạy lại tức thì và báo đỏ đúng chỗ vừa làm hỏng. Thử “gài bug” vào module Stack và xem test nào bắt được:

Chạy bộ test trên:
PASS happy Stack mới thì rỗng
PASS happy push rồi pop là LIFO
PASS edge count đếm đúng số phần tử
PASS error pop khi rỗng được xử lý (không sập)
PASS happy table-driven: 5 phần tử ra thứ tự nghịch
5/5 xanh - module hành xử đúng hợp đồng.
  • Bộ test tốt KHOANH VÙNG lỗi: chỉ test chạm behavior hỏng mới đỏ.
  • Bug LIFO→FIFO làm đỏ các test phụ thuộc thứ tự; bug count làm đỏ các test đọc count.
  • Không có test, một sửa đổi nhỏ có thể âm thầm phá hành vi cũ.

Một framework test, thực chất, chỉ gồm ba thứ: cách viết khẳng định, cách gom & chạy, và một mã thoát để ctest đọc. Đây là cả framework trong ~20 dòng:

tests/test.h - framework tí hon

#include <stdio.h>
static int tests_checks = 0, tests_failed = 0;

/* 1) Mot khang dinh - KHONG abort de cac test sau van chay */
#define CHECK(cond) do {                                       \
    tests_checks++;                                            \
    if (!(cond)) { tests_failed++;                             \
        printf("  THAT BAI: %s  (dong %d)\n", #cond, __LINE__); } \
} while (0)

/* 2) Gom & chay mot ham test */
#define RUN(test) do { printf("== %s ==\n", #test); test(); } while (0)

/* 3) Bao cao + ma thoat cho CTest (0 = tat ca xanh) */
#define TEST_REPORT() \
    (printf("\n%d kiem tra, %d that bai\n", tests_checks, tests_failed), \
     tests_failed == 0 ? 0 : 1)

#cond và __LINE__

#cond (toán tử “stringize” của bộ tiền xử lý) biến biểu thức thành chuỗi để in ra; __LINE__ là số dòng. Nhờ hai thứ này, khi đỏ bạn thấy ngay khẳng định nàodòng nào sai.

Một bộ test tốt phủ happy path, biên, và đường lỗi. Để ý test pop khi rỗng - nó bảo vệ nửa hợp đồng mà happy path bỏ qua. Lab bài này dùng bản stack_pop(s, &out) trả 0/1 - khác bản pop trả thẳng giá trị ở bài Đóng gói thư viện - chính để báo được lỗi khi rỗng:

tests/test_stack.c - Arrange · Act · Assert

#include "stack.h"
#include "test.h"

/* error path: pop khi RONG phai duoc xu ly, khong duoc sap */
static void test_pop_khi_rong_duoc_xu_ly(void) {
    Stack *s = stack_create();      /* ARRANGE */
    int x = 123;
    CHECK(stack_pop(s, &x) == 0);   /* ACT + ASSERT: bao that bai */
    CHECK(x == 123);                /* va KHONG ghi vao *out      */
    stack_destroy(s);
}

/* table-driven: mot du lieu, lap qua nhieu truong hop */
static void test_table_driven(void) {
    int input[] = {1, 2, 3, 4, 5};
    int n = (int)(sizeof input / sizeof input[0]);
    Stack *s = stack_create();
    for (int i = 0; i < n; i++) CHECK(stack_push(s, input[i]) == 1);
    for (int i = n - 1; i >= 0; i--) {   /* pop ra thu tu nguoc */
        int x = 0;
        CHECK(stack_pop(s, &x) == 1);
        CHECK(x == input[i]);
    }
    stack_destroy(s);
}

int main(void) {
    RUN(test_pop_khi_rong_duoc_xu_ly);
    RUN(test_table_driven);
    /* ...cac test khac... */
    return TEST_REPORT();
}

Kết quả khi chạy

== test_pop_khi_rong_duoc_xu_ly ==
== test_table_driven ==

17 kiem tra, 0 that bai

Đăng ký test với CMake để chạy bằng ctest - mã thoát khác 0 là test đỏ:

CMakeLists.txt (phần test)

add_library(stack src/stack.c)
target_include_directories(stack PUBLIC include)

enable_testing()                              # bat ctest
add_executable(test_stack tests/test_stack.c)
target_link_libraries(test_stack PRIVATE stack)
add_test(NAME stack_unit COMMAND test_stack)  # ctest se chay no

Dự án thật thường dùng framework có sẵn như Unity hay CMocka - thêm setUp/tearDown và nhiều kiểu assert. Cùng test LIFO, viết với Unity:

cùng test đó, bằng Unity

#include "unity.h"
#include "stack.h"

void setUp(void) {}      /* chay TRUOC moi test  */
void tearDown(void) {}   /* chay SAU moi test    */

void test_lifo(void) {
    Stack *s = stack_create();
    stack_push(s, 10);
    stack_push(s, 20);
    int x = 0;
    stack_pop(s, &x);
    TEST_ASSERT_EQUAL_INT(20, x);   /* assert co kieu cua Unity */
    stack_destroy(s);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_lifo);
    return UNITY_END();
}
Chạy trên máy bạn - kỹ năng thật cần gõ lệnh thật

Lab module Stack + framework tí hon + CTest. Chạy ctest cho xanh, gài bug để xem test bắt, rồi thêm test mới. Chạy thử dưới sanitizer với -DSAN=ON.

$ cmake -S . -B build
$ cmake --build build
$ ctest --test-dir build --output-on-failure
Xem từng file (6) ↡

Liên hệ Toolchain

Cách dựng CI chạy test mỗi lần push, gắn Unity/CMocka qua CMake, và test double (giả malloc để test nhánh hết bộ nhớ) - học sâu ở khoá C Thực Chiến · Toolchain (bài Gỡ lỗi · Kiểm thử · CI).

Tiếp theo: integration test

Unit test bảo vệ từng module riêng. Nhưng phần mềm hỏng nhiều nhất ở chỗ các module GHÉP nhau - lưu rồi đọc lại file, một module gọi module kia. Bài tiếp theo, Integration test, lo đúng những chỗ tiếp giáp đó.

Câu hỏi thường gặp

Unit test kiểm MỘT đơn vị (một hàm/module) CÔ LẬP - nhanh, không đụng file/mạng. Integration test kiểm NHIỀU đơn vị GHÉP với nhau ở chỗ tiếp giáp (vd module + lưu file). Bài này lo unit; integration ở bài tiếp theo.

Khuôn ba bước của một test: ARRANGE dựng cảnh (tạo stack, đẩy vài phần tử); ACT gọi đúng MỘT hành vi cần kiểm (stack_pop); ASSERT khẳng định kết quả mong đợi (giá trị + mã trả về). Một test tốt chỉ kiểm một hành vi để khi đỏ, bạn biết ngay hỏng ở đâu.

Vì bug hay nấp ở biên và đường lỗi: pop khi rỗng, malloc thất bại, chỉ số ngoài phạm vi. Hợp đồng của hàm gồm CẢ các trường hợp này (vd “pop rỗng trả 0, không chạm *out”) - nên phải có test cho chúng, nếu không nửa hợp đồng không được bảo vệ.

Là một bản GIẢ thay cho một phụ thuộc thật, để ép một tình huống khó tái hiện. Vd muốn test nhánh stack_create trả NULL khi hết bộ nhớ, bạn thay malloc bằng một bản luôn thất bại (một “stub”). Nhờ đó test được đường lỗi mà bình thường gần như không xảy ra.

Để thấy bản chất một framework chỉ là ba thứ: cách viết khẳng định (CHECK), cách gom & chạy (RUN), và một mã thoát để CTest đọc. Hiểu cơ chế rồi thì dùng Unity/CMocka (phong phú hơn: setUp/tearDown, nhiều kiểu assert) sẽ rất nhanh.

Rất nhanh (mili-giây) và KHÔNG phụ thuộc thứ tự hay trạng thái dùng chung - mỗi test tự dựng cảnh riêng và dọn sạch. Nhờ vậy chạy được hàng trăm test mỗi lần sửa code, và một test đỏ luôn tái hiện được.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Khuôn Arrange · Act · Assert của một unit test nghĩa là gì?

  1. 1

    Đọc hợp đồng

    Mở stack.h trong lab. stack_pop hứa gì khi stack RỖNG? Viết bằng lời.

    Hoàn thành khi: Trả về 0 (thất bại) và KHÔNG ghi vào *out. Đó là hành vi mà test đường lỗi phải kiểm.

  2. 2

    Chạy bộ test

    Build lab và chạy ctest. Kết quả?

    Hoàn thành khi: ctest xanh: “17 kiem tra, 0 that bai”, 1/1 test pass.

  3. 3

    Để test bắt bug

    Sửa stack_pop lấy s->data[0] (biến LIFO thành FIFO), chạy lại. Test nào đỏ?

    Hoàn thành khi: test_push_roi_pop_la_lifo báo “THAT BAI: x == 20”; table-driven cũng đỏ. Khôi phục để xanh lại.

  4. 4

    Thêm một unit test

    Viết test_push_lon_hon_suc_chua: push 6 phần tử (ép mảng grow), kiểm count == 6 và pop ra đủ.

    Hoàn thành khi: Test mới xanh; nếu grow sai, nó bắt được.

  5. 5

    Nghĩ về test double

    Muốn test nhánh stack_create trả NULL (hết bộ nhớ) mà không thật sự hết RAM - làm sao?

    Hoàn thành khi: Thay malloc bằng một stub luôn trả NULL (test double), rồi khẳng định stack_create trả NULL.

  6. 6

    Test dưới sanitizer

    Build lab với -DSAN=ON rồi chạy ctest. Vì sao nên chạy test dưới ASan?

    Hoàn thành khi: Test vừa kiểm KẾT QUẢ đúng, vừa lộ lỗi bộ nhớ ẩn (rò rỉ, ngoài biên) khi chạy.