Chương 29. Unit test host-based và giả lập phần cứng
Mục tiêu
- Viết unit test cho logic thuần (FSM, thuật toán) chạy trên host, không cần MCU/board.
- Kiểm thử driver phụ thuộc phần cứng bằng cách tiêm (inject) một bus/GPIO giả lập, tận dụng HAL kiểu template (chương 25).
- Biết vì sao tách "logic" khỏi "I/O trực tiếp" là điều kiện tiên quyết để test được, và cách các framework thật (Catch2, doctest, GoogleTest) khác gì bản tối giản trong chương.
Câu chuyện: bug chỉ tái hiện được trên board thật
Một lỗi trong FSM chống rung phím (chương 26) chỉ xuất hiện với một chuỗi tín hiệu hiếm gặp (rung đúng vào thời điểm biên bộ đếm). Tìm lỗi bằng cách nhấn nút thật trên board hàng chục lần là chuyện may rủi. Nếu Button không phụ thuộc gì vào GPIO thật — chỉ nhận bool đầu vào và trả bool đầu ra — nó có thể được test bằng cách "phát lại" chính xác chuỗi tín hiệu gây lỗi, trên máy phát triển, chạy trong mili giây, lặp lại được vô hạn lần.
Kiến thức
1. Test logic thuần, không cần framework ngoài
// Mot bo test toi gian (khong can thu vien ngoai) de kiem thu logic THUAN (khong phan cung) tren host.
// Trong du an that, Catch2/doctest/GoogleTest lam viec nay tot hon nhieu (bao cao dep, nhieu tien ich) -
// xem phan van ban ve cach them qua CMake FetchContent. O day tu viet de vi du KHONG can gi ngoai g++.
#include <cstdio>
#include <string_view>
static int g_pass = 0;
static int g_fail = 0;
#define CHECK(cond) \
do { \
if (cond) { ++g_pass; } \
else { ++g_fail; std::printf(" THAT BAI: %s (dong %d)\n", #cond, __LINE__); } \
} while (0)
void run_test(std::string_view name, void (*fn)()) {
std::printf("test: %.*s\n", static_cast<int>(name.size()), name.data());
fn();
}
// ---- Doi tuong can kiem thu: mot FSM chong rung don gian, KHONG dung GPIO that (nhac lai chuong 26) ----
enum class ButtonState { Released, Debouncing, Pressed };
class Button {
public:
bool tick(bool raw_pressed) {
bool edge = false;
switch (state_) {
case ButtonState::Released:
// Dem tu 1: chinh tick nay DA la tick dau tien thay tin hieu nhan.
if (raw_pressed) { state_ = ButtonState::Debouncing; counter_ = 1; }
break;
case ButtonState::Debouncing:
if (!raw_pressed) { state_ = ButtonState::Released; break; }
if (++counter_ >= 3) { state_ = ButtonState::Pressed; edge = true; }
break;
case ButtonState::Pressed:
if (!raw_pressed) state_ = ButtonState::Released;
break;
}
return edge;
}
bool is_pressed() const { return state_ == ButtonState::Pressed; }
private:
ButtonState state_ = ButtonState::Released;
int counter_ = 0;
};
// ---- Cac ca kiem thu: moi ham la MOT kich ban, doc lap, khong phu thuoc thu tu chay ----
void test_button_khong_bao_gio_nhan_thi_khong_bao_gio_pressed() {
Button b;
for (int i = 0; i < 10; ++i) CHECK(!b.tick(false));
CHECK(!b.is_pressed());
}
void test_button_nhan_du_lau_thi_chuyen_sang_pressed() {
Button b;
CHECK(!b.tick(true)); // tick 1: chua du debounce
CHECK(!b.tick(true)); // tick 2
CHECK(b.tick(true)); // tick 3: canh "vua nhan" xuat hien dung 1 lan
CHECK(b.is_pressed());
CHECK(!b.tick(true)); // giu nguyen: khong co canh moi
}
void test_button_rung_ngan_khong_tinh_la_nhan() {
Button b;
CHECK(!b.tick(true));
CHECK(!b.tick(false)); // rung: nha ra truoc khi du debounce
CHECK(!b.is_pressed());
}
int main() {
run_test("khong nhan -> khong bao gio pressed", test_button_khong_bao_gio_nhan_thi_khong_bao_gio_pressed);
run_test("nhan du lau -> chuyen sang pressed dung mot lan", test_button_nhan_du_lau_thi_chuyen_sang_pressed);
run_test("rung ngan -> khong tinh la nhan", test_button_rung_ngan_khong_tinh_la_nhan);
std::printf("\nKET QUA: %d dat, %d that bai\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
test: khong nhan -> khong bao gio pressed
test: nhan du lau -> chuyen sang pressed dung mot lan
test: rung ngan -> khong tinh la nhan
KET QUA: 19 dat, 0 that bai
Button ở đây giống hệt tinh thần chương 26: không #include gì về GPIO, chỉ nhận bool raw_pressed và trả cạnh sự kiện. Vì vậy nó test được trực tiếp bằng vài dòng macro tối giản (CHECK), không cần thư viện ngoài, không cần mock gì — đây là lợi ích lớn nhất của việc tách logic khỏi phần cứng.
Ba bài test minh hoạ ba kịch bản, không phải ba dòng code ngẫu nhiên:
- Không bao giờ nhận → không bao giờ chuyển sang
Pressed. - Nhận đủ lâu → chuyển sang
Pressed, và cạnh sự kiện chỉ xuất hiện đúng một lần tại thời điểm chuyển. - Rung ngắn (nhả trước khi đủ debounce) → không được tính là một lần nhấn.
Mỗi bài test độc lập (tạo Button b mới), không phụ thuộc thứ tự chạy — nguyên tắc quan trọng để test đáng tin cậy.
2. Test driver phụ thuộc phần cứng bằng bus giả lập
// Kiem thu mot driver PHU THUOC phan cung bang cach "tiem" (inject) mot bus GIA LAP thay vi bus that -
// ket hop HAL kieu concept/template cua chuong 25 voi kiem thu host-based. Khong can MCU nao ca.
#include <array>
#include <concepts>
#include <cstdint>
#include <cstdio>
template <typename T>
concept I2cBus = requires(T bus, std::uint8_t addr, std::uint8_t reg, std::uint8_t value) {
{ bus.write_reg(addr, reg, value) } -> std::same_as<void>;
{ bus.read_reg(addr, reg) } -> std::same_as<std::uint8_t>;
};
// Driver that su (giong tinh than chuong 25): doc nhiet do, bao "qua nhiet" neu vuot nguong.
template <I2cBus Bus>
class ThermalGuard {
public:
ThermalGuard(Bus& bus, std::uint8_t addr, std::uint8_t temp_reg, std::uint8_t limit)
: bus_(bus), addr_(addr), temp_reg_(temp_reg), limit_(limit) {}
bool is_overheating() { return bus_.read_reg(addr_, temp_reg_) > limit_; }
private:
Bus& bus_;
std::uint8_t addr_, temp_reg_, limit_;
};
// Bus GIA LAP cho kiem thu: khong noi voi phan cung, chi tra ve gia tri da lap trinh san va
// GHI LAI cac loi goi de bai test kiem tra ("da goi dung dia chi/thanh ghi chua?").
class MockBus {
public:
void write_reg(std::uint8_t addr, std::uint8_t reg, std::uint8_t value) {
last_write_addr_ = addr;
last_write_reg_ = reg;
last_write_value_ = value;
++write_count_;
}
std::uint8_t read_reg(std::uint8_t addr, std::uint8_t reg) {
last_read_addr_ = addr;
last_read_reg_ = reg;
++read_count_;
return programmed_value_;
}
void program_read_value(std::uint8_t v) { programmed_value_ = v; }
int read_count() const { return read_count_; }
std::uint8_t last_read_addr() const { return last_read_addr_; }
private:
std::uint8_t programmed_value_ = 0;
std::uint8_t last_write_addr_ = 0, last_write_reg_ = 0, last_write_value_ = 0;
std::uint8_t last_read_addr_ = 0, last_read_reg_ = 0;
int write_count_ = 0, read_count_ = 0;
};
static int g_pass = 0, g_fail = 0;
#define CHECK(cond) \
do { \
if (cond) { ++g_pass; } \
else { ++g_fail; std::printf(" THAT BAI: %s (dong %d)\n", #cond, __LINE__); } \
} while (0)
int main() {
MockBus bus;
ThermalGuard guard(bus, 0x50, 0x01, /*limit=*/80); // CTAD: suy ra ThermalGuard<MockBus>
bus.program_read_value(50);
CHECK(!guard.is_overheating()); // 50 <= 80: binh thuong
bus.program_read_value(95);
CHECK(guard.is_overheating()); // 95 > 80: qua nhiet
// Kiem tra driver da doc DUNG dia chi thiet bi, khong doc nham dia chi khac tren cung bus.
CHECK(bus.last_read_addr() == 0x50);
CHECK(bus.read_count() == 2);
std::printf("KET QUA: %d dat, %d that bai\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
KET QUA: 4 dat, 0 that bai
ThermalGuard<Bus> (cùng mẫu HAL kiểu concept ở chương 25) không quan tâm Bus là I2C thật hay giả lập. MockBus đóng hai vai trò kinh điển của một "test double":
- Stub:
program_read_valuelập trình sẵn giá trị trả về, để test kiểm soát hoàn toàn kịch bản (nhiệt độ 50 rồi 95) mà không cần cảm biến thật nóng lên. - Spy:
last_read_addr()/read_count()ghi lại cách driver đã gọi bus, để test xác nhận driver đọc đúng địa chỉ/thanh ghi — bắt được lỗi kiểu "đọc nhầm địa chỉ nhưng tình cờ vẫn ra kết quả đúng" mà chỉ kiểm tra giá trị trả về không phát hiện được.
Vì ThermalGuard nhận Bus& qua constructor thay vì tự tạo bus bên trong, nó có thể nhận bất kỳ kiểu nào thoả concept I2cBus — đây chính là "dependency injection" (tiêm phụ thuộc), không cần framework, chỉ cần thiết kế API nhận phụ thuộc từ bên ngoài thay vì tự khởi tạo bên trong.
3. Framework thật: Catch2/doctest/GoogleTest
Bản CHECK/run_test trong chương chỉ có vài dòng, đủ để dạy khái niệm. Trong dự án thật, một framework như Catch2 hay doctest cho: gom nhóm test theo file, chạy chọn lọc theo tên/tag, so sánh giá trị in ra đẹp khi thất bại, đo thời gian, tích hợp CI. Cấu trúc thường gặp (tham khảo, kiểm tra đúng phiên bản trong tài liệu chính thức của framework bạn chọn):
TEST_CASE("Button debounce bo qua rung ngan") {
Button b;
REQUIRE(b.tick(true) == false);
REQUIRE(b.tick(false) == false);
REQUIRE(b.is_pressed() == false);
}
Thêm framework này vào dự án nhúng thường qua CMake (FetchContent hoặc submodule), biên dịch và chạy trên host (x86/x64), tách biệt hoàn toàn khỏi bản build cho MCU thật — đây là ý nghĩa của "host-based testing" trong tiêu đề chương.
Góc nhúng
- Test host chạy trong CI (GitHub Actions...) mỗi lần commit — bắt lỗi logic trước khi nạp lên board, rẻ hơn rất nhiều so với debug trên phần cứng.
- Không phải mọi thứ test được trên host: timing chính xác, điện áp thực tế, nhiễu điện từ... vẫn cần board thật. Unit test host-based bổ sung, không thay thế, kiểm thử tích hợp trên phần cứng.
- Driver càng tách rõ "quyết định" (logic) khỏi "hành động" (ghi thanh ghi), càng dễ test — đây là lý do thiết kế HAL ở chương 25 và FSM thuần ở chương 26 không chỉ đẹp về mặt kiến trúc mà còn thực dụng cho việc test.
Lỗi thường gặp
- Viết driver gọi thẳng địa chỉ MMIO cố định bên trong logic nghiệp vụ, không qua tham số/interface — không thể thay thế bằng giả lập để test.
- Test phụ thuộc thứ tự chạy (test sau dựa vào trạng thái global bị test trước thay đổi) — thất bại ngẫu nhiên khi đổi thứ tự hoặc chạy song song.
- Chỉ test "đường vui" (giá trị hợp lệ), bỏ qua biên và trường hợp lỗi (bus trả giá trị bất thường, timeout) — thường chính là nơi bug thật xảy ra.
- Nhầm mock thay thế hoàn toàn hành vi cần test — ví dụ mock trả
truevô điều kiện chois_overheating()thay vì mô phỏng đúng bus, khiến test không còn kiểm tra được gì có ý nghĩa.
Bài tập
- Thêm bài test cho
Button: nhấn, thả đúng lúc đạt ngưỡng debounce (không sớm hơn, không muộn hơn) — kiểm tra biên chính xác của bộ đếm. - Thêm phương thức
MockBus::fail_next_read()khiến lần đọc tiếp theo trả về một giá trị đặc biệt (ví dụ0xFF, thường là dấu hiệu lỗi bus thật); viết test choThermalGuardxử lý tình huống đó (hiện tại nó coi0xFFlà nhiệt độ hợp lệ — có nên sửa không?). - Viết một bài test cố tình thất bại (sửa
CHECKvới điều kiện sai) để quan sát định dạng thông báo lỗi, rồi sửa lại đúng. - Nếu có sẵn CMake và mạng, thử thêm Catch2 qua
FetchContentvào một dự án con nhỏ và viết lại một trong các test của chương bằngTEST_CASE/REQUIRE— so sánh trải nghiệm với bảnCHECKtối giản.
Tóm tắt
- Logic tách khỏi I/O trực tiếp (FSM thuần, HAL qua interface/template) là điều kiện để test được trên host mà không cần phần cứng.
- Bus/GPIO giả lập (test double) đóng vai stub (trả giá trị lập trình sẵn) và spy (ghi lại lời gọi) để kiểm tra cả kết quả lẫn cách driver hoạt động.
- Unit test host-based chạy nhanh, lặp lại được, hợp với CI — bổ sung cho, không thay thế, kiểm thử tích hợp trên board thật.