78、数据库单元测试:测试框架选择(CUnit/Unity)、Mock数据库、测试数据管理

单元测试这件事,说实话,在嵌入式数据库开发里经常被忽视。

我见过太多项目,代码写得飞起,一跑起来就崩。为什么?因为数据库操作太依赖底层环境了。你想想看,每次测试都要连真实数据库,数据一乱,测试结果就跟着乱。这哪是测试,分明是碰运气。

今天我们就来聊聊,怎么给C语言写的数据库模块做单元测试。我会从框架选择、Mock数据库、测试数据管理三个角度展开。嗯,都是我在项目里踩过的坑,希望能帮你少走弯路。

78.1 测试框架怎么选?CUnit vs Unity

做C语言的单元测试,框架就那么几个。我个人最常用的是CUnit和Unity。它们俩风格不太一样,适合的场景也不同。

特性 CUnit Unity
安装方式 需要编译安装库 单文件,直接拷贝
测试用例组织 Suite + Test 层级 函数式,轻量
断言类型 丰富(CU_ASSERT_EQUAL等) 基础(TEST_ASSERT_EQUAL等)
Mock支持 无内置,需配合CMock 无内置,需配合CMock
适合场景 大型项目,结构化测试 嵌入式,资源受限环境

我个人习惯:如果项目跑在Linux服务器上,资源充裕,我会选CUnit。它的Suite机制很适合组织大量测试用例。但如果是嵌入式环境,比如STM32或者RTOS上跑数据库,Unity是更好的选择——就一个.c和一个.h,不占地方。

核心观点:框架选型取决于你的运行环境。别为了炫技选重的,够用就好。

78.1.1 CUnit 示例:测试数据库打开函数

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>
#include "db_core.h"

void test_db_open_success(void) {
    int rc = db_open("test.db");
    CU_ASSERT_EQUAL(rc, DB_OK);
    CU_ASSERT_PTR_NOT_NULL(get_db_handle());
}

void test_db_open_invalid_path(void) {
    int rc = db_open(NULL);
    CU_ASSERT_EQUAL(rc, DB_ERR_INVALID_PARAM);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("DB_Open_Suite", NULL, NULL);
    CU_add_test(suite, "test open success", test_db_open_success);
    CU_add_test(suite, "test open invalid path", test_db_open_invalid_path);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

你看,CUnit的写法很清晰。先建Suite,再往里加测试用例。跑起来会输出每个用例的通过/失败状态。我在项目中遇到过一个问题:如果测试用例多了,Suite的初始化/清理函数没写好,会导致内存泄漏。所以记得在Suite里加init和clean回调。

78.1.2 Unity 示例:同样的测试,更轻量

#include "unity.h"
#include "db_core.h"

void setUp(void) {
    // 每个测试前执行
}

void tearDown(void) {
    // 每个测试后执行
}

void test_db_open_success(void) {
    TEST_ASSERT_EQUAL_INT(DB_OK, db_open("test.db"));
    TEST_ASSERT_NOT_NULL(get_db_handle());
}

void test_db_open_invalid_path(void) {
    TEST_ASSERT_EQUAL_INT(DB_ERR_INVALID_PARAM, db_open(NULL));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_db_open_success);
    RUN_TEST(test_db_open_invalid_path);
    return UNITY_END();
}

Unity的写法更直接。没有Suite的概念,就是一个个函数。setUp和tearDown是框架自动调用的,适合做资源准备和清理。我曾经在一个资源受限的MCU上跑Unity,整个测试代码加上框架才占不到10KB的Flash,非常舒服。

78.2 Mock数据库:让测试脱离真实环境

为什么要Mock数据库?说白了,就是不想让测试依赖真实的文件系统或硬件。

你想想看,如果每次跑单元测试都要先准备一个真实的数据库文件,测试完还要清理,多麻烦。更糟糕的是,如果测试用例之间互相影响,数据污染了,你根本不知道是代码bug还是测试环境的问题。

Mock的核心思路:用函数指针或者编译开关,把真实的数据库操作替换成模拟实现。

技巧:我建议在头文件里定义一组可替换的函数指针。测试时注入Mock实现,生产代码里用真实实现。这样不用改业务代码。

78.2.1 可替换的数据库接口设计

// db_interface.h
typedef int (*db_open_func)(const char *path);
typedef int (*db_close_func)(void);
typedef int (*db_exec_func)(const char *sql);

typedef struct {
    db_open_func open;
    db_close_func close;
    db_exec_func exec;
} db_interface_t;

extern db_interface_t g_db_if;

// 真实实现
int real_db_open(const char *path);
int real_db_close(void);
int real_db_exec(const char *sql);

// Mock实现
int mock_db_open(const char *path);
int mock_db_close(void);
int mock_db_exec(const char *sql);

这样设计的好处是:测试代码里只需要把g_db_if指向Mock函数,业务代码完全不用动。我在一个项目里用这个模式,把原本需要真实SQLite环境的测试,全部改成了Mock,测试速度从分钟级降到了秒级。

78.2.2 Mock实现示例

// mock_db.c
static int s_open_called = 0;
static int s_close_called = 0;
static char s_last_sql[256];

int mock_db_open(const char *path) {
    s_open_called = 1;
    // 不真的打开文件,只记录
    return DB_OK;
}

int mock_db_close(void) {
    s_close_called = 1;
    return DB_OK;
}

int mock_db_exec(const char *sql) {
    strncpy(s_last_sql, sql, sizeof(s_last_sql) - 1);
    // 模拟返回成功
    return DB_OK;
}

// 测试中检查Mock状态
void test_db_exec_insert(void) {
    g_db_if.open = mock_db_open;
    g_db_if.close = mock_db_close;
    g_db_if.exec = mock_db_exec;

    db_exec("INSERT INTO users VALUES(1, 'alice')");
    TEST_ASSERT_EQUAL_INT(1, s_open_called);
    TEST_ASSERT_EQUAL_STRING("INSERT INTO users VALUES(1, 'alice')", s_last_sql);
}

注意:Mock实现里不要做真实的I/O操作。一旦Mock里写了文件,测试就又依赖环境了。我曾经犯过这个错,Mock里偷偷写日志文件,结果CI环境没有写权限,测试全挂了。

78.3 测试数据管理:别让数据成为测试的绊脚石

测试数据管理,说白了就是解决两个问题:

  • 测试数据从哪里来?
  • 测试数据怎么保证每次一致?

我见过最糟糕的做法:测试用例里硬编码数据,然后多个用例共享同一个全局变量。结果一个用例改了数据,另一个用例就挂了。调试起来简直想砸电脑。

78.3.1 数据隔离:每个测试用例独立的数据

我的做法是:每个测试用例都自己准备数据,用完就清理。不要依赖其他用例留下的数据。

void test_query_user_by_id(void) {
    // 准备数据
    db_exec("INSERT INTO users VALUES(1, 'bob')");
    db_exec("INSERT INTO users VALUES(2, 'carol')");

    // 执行查询
    user_t u;
    int rc = query_user_by_id(1, &u);
    TEST_ASSERT_EQUAL_INT(DB_OK, rc);
    TEST_ASSERT_EQUAL_STRING("bob", u.name);

    // 清理数据
    db_exec("DELETE FROM users");
}

你可能会说:每个用例都插入删除,效率低啊。没错,但单元测试追求的是确定性,不是性能。如果实在觉得慢,可以用事务包裹:测试开始前begin,测试结束后rollback。这样数据不会真的写入磁盘。

78.3.2 测试数据生成:用结构体数组批量准备

当测试数据比较多时,一个个写INSERT太累了。我习惯用结构体数组来组织测试数据。

typedef struct {
    int id;
    const char *name;
    int age;
} test_user_t;

static const test_user_t s_test_users[] = {
    {1, "alice", 30},
    {2, "bob", 25},
    {3, "carol", 28},
    {4, "dave", 35},
};

void prepare_test_users(void) {
    for (int i = 0; i < 4; i++) {
        char sql[128];
        snprintf(sql, sizeof(sql),
                 "INSERT INTO users VALUES(%d, '%s', %d)",
                 s_test_users[i].id,
                 s_test_users[i].name,
                 s_test_users[i].age);
        db_exec(sql);
    }
}

这样写的好处是:数据定义和操作分离。想加测试数据,只需要在数组里加一行。想改数据,改数组就行,不用改SQL。

核心原则:测试数据要可重复、可隔离、可追溯。每次跑测试,数据状态都是确定的。这样测试失败时,你才能快速定位是代码问题还是数据问题。

78.4 本章知识体系图

下面这张图总结了数据库单元测试的核心流程和关键决策点。你可以把它当作一个检查清单。

数据库单元测试核心流程 选择测试框架 CUnit / Unity Mock数据库 函数指针 / 编译开关 测试数据管理 隔离 / 可重复 关键决策:运行环境 Linux服务器 → CUnit | 嵌入式 → Unity CUnit 特点 Suite组织 / 丰富断言 Unity 特点 轻量 / 单文件 / 适合MCU 目标:脱离环境依赖,快速定位问题 Mock + 数据隔离 = 可靠的单元测试

这张图把整个流程串起来了。从选择框架开始,到Mock数据库,再到测试数据管理,最后落到一个目标:让测试脱离环境依赖。你写测试的时候,可以对着这张图检查每一步有没有做到位。

个人经验:刚开始做数据库单元测试时,别想着一步到位。先选一个框架,把最简单的打开/关闭函数测起来。Mock也先从最核心的接口开始。等跑顺了,再慢慢加复杂场景。我曾经一上来就想Mock所有接口,结果Mock代码比业务代码还多,得不偿失。

好了,关于数据库单元测试的框架选择、Mock实现和数据管理,就聊到这里。这些方法我在多个项目里验证过,确实能提高测试的稳定性和效率。你动手试试看,有问题随时交流。


公众号:蓝海资料掘金营,微信deep3321