6

单元测试

单元测试,那些似乎从来都不怎么有用的软件工程教科书里有关于它的确切定义。在本文中,只是将其视为检验某个函数是否正确的一种手段。可将单元测试想象为质检员,将要检验的函数想象为待出厂的零件

例如,我想检验我实现的双向链表容器 PmList 的 pm_list_append 函数,我可以为它写一个测试程序:

#include <stdio.h>
#include <stdlib.h>
#include <pmlist.h>

static void
pm_test_list_append(void)
{
        size_t N = 10;
        int numbers[N];
        PmList *list = pm_list_alloc();
        for (int i = 0; i < N; i++) {
                numbers[i] = i;
                pm_list_append(list, numbers + i);
        }
        
        int *_numbers = numbers;
        pm_list_foreach(list, it) {
                int *i = it->data;
                if (*i != *(_numbers++)) {
                        printf("** Error **: pm_list_append() error!\n");
                        abort();
                }
        }
        if (pm_list_size(list) != N) {
                printf("** Error **: pm_list_append() error!\n");
                abort();
        }

        pm_list_free(list);
}

int
main(void)
{
        pm_test_list_append();
        return 0;
}

这个测试程序的思路很简单,就是将一个长度为 10 的整型数组中每个数据对应的内存地址存储到 PmList 容器中,然后遍历 PmList,将每个结点中存储的数据还原为正确类型的指针,然后再比较一下指针所指向的数据是否就是数组中的数据,最后验证一下 PmList 的长度是否与数组相等。

单元测试不仅仅用于测试某个函数的正确性,它还可以作为回归测试的基础。所谓回归测试,就是说每当我们对现有的代码作了修改,那么便需要重新进行单元测试,以确定我们所作的修改没有引入新的错误。

单元测试框架

上一节的示例程序虽然是一个单元测试程序,但是它非常初级,主要表现在三个方面:

  • 缺乏适度的规范

  • 易造成代码冗余

  • 自动化程度偏低

缺乏适度的规范会导致测试程序在编写过程中充满了随意性。例如下面的代码片段:

if (pm_list_size(list) != N) {
        printf("** Error **: pm_list_append() error!\n");
        abort();
}

同样的功能,可能每个人写出来的代码也不尽相同。例如我经常写成:

if (pm_list_size(list) != N) {
        printf ("** Error **: pm_list_append() error!\n");
        exit(-1);
}

也可能有人只需要输出错误信息,而不想让测试程序中断,他可能会写出下面的代码:

if (pm_list_size(list) != N) {
        printf ("** Error **: pm_list_append() error!\n");
        return;
}

虽然这种随意性虽然对于单元测试程序的功能方面没有影响,但是风格迥异的源码可能会让单元测试程序的维护者有些抓狂。

易造成代码冗余主要是因为测试代码中存在较多的重复部分。例如下面这样的代码片段:

if (*i != *(_numbers++)) {
        printf("** Error **: pm_list_append() error!\n");
        abort();
}

if (pm_list_size(list) != N) {
        printf("** Error **: pm_list_append() error!\n");
        abort();
}

只需定义一个宏或者函数便可以大幅削减此类代码。例如定义一个 my_assert_cmp_int 宏:

#define my_assert_cmp_int(n1, cmp, n2, func_name) \
        do { int __n1 = (n1), __n2 = (n2); \
             char *__func_name = (func_name); \
             if (__n1 cmp __n2) ; \
             else {\
                     printf ("** Error **: %s error!\n", __func_name); \
                     abort ();
        } while (0)

使用这个宏,便可以轻易的将上面的测试代码削减为:

my_assert_cmp_int(*i,  ==, *(_numbers++), "pm_list_append");

my_assert_cmp_int(pm_list_size(list),  ==, N, "pm_list_append");

自动化程度低是指前文中 PmList 单元测试这样的程序,在运行过程中不具备忽略出错的测试函数的能力。单元测试程序如果能够忽略出错的测试函数,这意味着我们可以使用它对模块形成一个完整的测试过程。也就是说,即便在单元测试中遇到了故障,我们依然希望测试程序能够继续完成后续的测试任务。例如,PmList 单元测试程序包含了下面的代码片段:

if (pm_list_size(list) != INT_ARRAY_SIZE) {
    printf("** Error **: pm_list_append() error!\n");
    abort();
}

如果 pm_list_append 函数的实现存在错误的话,很可能会导致上述代码中的 if 语句生效,从而导致测试程序终止。如果单元测试程序中包含着上百个测试函数,我们可能不希望第 1 个测试函数出错而导致后面的测试函数没有机会运行。

由于初级的单元测试方法太弱了,所以单元测试框架便应运而生。它的使命是致力实现单元测试的规范化与自动化,并消减单元测试代码冗余。

自行实现一个单元测试框架并不是很难,但是现在单元测试框架的轮子已经足够多了,我们没有必要再去造一个。对于 C 语言程序的单元测试而言,可以选用 Check 库[1]、CUnit 库[2]或者 GLib 库所包含的一个单元测试框架(Testing 模块)。由于我们的项目原本就依赖 GLib,所以就别无二话的用上了 GLib 的 Testing。

GLib 测试框架

有关 GLib 测试框架的参考手册见文档[3]。文档[4-6]是我能找到的最详尽的 GLib 测试框架使用说明。

GLib 测试框架主要包含三个概念:

  • 测试案例(Test Case):由测试函数与夹具(Fixture)构成。

  • 夹具(估计是从机械领域引入的概念,被测的函数被视为零件):为测试函数准备数据,并负责测试环境的构建与销毁。

  • 测试集(Test Suit):由测试案例构成的集合。

下面,我们基于 GLib 测试框架改写前文中的 PmList 单元测试程序,详见以下代码:

#include <glib.h>
#include <pmlist.h>

static void
pm_test_list_append(void)
{
        gsize N = 10;
        gint numbers[N];
        PmList *list = pm_list_alloc();
        for (gint i = 0; i < N; i++) {
                numbers[i] = i;
                pm_list_append(list, numbers + i);
        }
        
        int *_numbers = numbers;
        pm_list_foreach(list, it) {
                gint *i = it->data;
                g_assert_cmpint(*i, ==, *(_numbers++));
        }
        g_assert_cmpint(pm_list_size(list), ==, N);
        
        pm_list_free(list);
}

int
main(int argc, char **argv)
{
        g_test_init(&argc, &argv, NULL);
        g_test_add_func("/PmList/pm_list_append", pm_test_list_append);
        
        return g_test_run();
}

可以看出,除了头文件有所变动之外,我们对 main 函数和 pm_test_list_append 函数均作了一些改动。对 main 函数所做的修改主要是添加了以下代码:

        g_test_init(&argc, &argv, NULL);
        g_test_add_func("/PmList/pm_list_append", pm_test_list_append);

        return g_test_run();

g_test_init 函数从 main 函数的命令行参数集合中截获 GLib 测试框架感兴趣的参数,并完成单元测试框架的初始化。

g_test_add_func 函数可以一起呵成的建立测试集与测试案例。我们向 g_gest_add_func 函数提供的第一个参数是测试路径 /PmList/pm_list_append,用于指示 g_gest_add_func 函数去创建测试集 PmList 和测试案例 pm_list_append;第二个参数是测试函数,例如 pm_test_list_append 函数。

如果 PmList 模块包含着许多测试案例,那么我们可以使用 g_test_add_func 函数将它们陆续添加至 PmList 中,例如:

        g_test_add_func("/PmList/pm_list_append", pm_test_list_append);
        g_test_add_func("/PmList/pm_list_insert", pm_test_list_insert);
        g_test_add_func("/PmList/pm_list_prepend", pm_test_list_prepend);
        ... ...

当测试集与测试案例构建完毕后,便可使用 g_test_run 函数运行测试集合中的所有测试案例。由于 g_test_run 函数是单元测试程序中最后执行的函数,所以它的返回值可直接作为 main 函数的返回值。

GLib 测试框架除了提供单元测试核心概念(测试集、夹具、测试案例等)的实现,也提供了一些测试宏,类似我们在前文中定义的 my_assert_cmp_int 这样的宏。我们使用这些宏替换了原先的 if 测试语句,例如将

        if (pm_list_size(list) != N) {
                printf("** Error **: pm_list_append () error!\n");
                abort();
        }

替换为:

        g_assert_cmpint(pm_list_size(list), ==, N);

然后在 Bash Shell 中执行以下命令完成 PmList 模块以及测试程序的编译:

# 编译 PmList 模块,生成 libpmbase 库
$ libtool --tag=CC --mode=compile gcc `pkg-config --cflags glib-2.0` -c pmlist.c
$ libtool --tag=CC --mode=link gcc `pkg-config --libs glib-2.0` pmlist.lo -rpath /usr/local/lib -o libpmbase.la

# 编译单元测试程序
$ libtool --tag=CC --mode=compile gcc -I./ `pkg-config --cflags glib-2.0` -std=c99 -c test.c
$ libtool --tag=CC --mode=link gcc libpmbase.la test.lo -o test

如果你和我一样雅量,用的是 Fish Shell,那么上述命令需要变换为:

# 编译 PmList 模块,生成 libpmbase 库
$ eval libtool --tag=CC --mode=compile gcc (pkg-config --cflags glib-2.0) -c pmlist.c
$ eval libtool --tag=CC --mode=link gcc (pkg-config --libs glib-2.0) pmlist.lo -rpath /usr/local/lib -o libpm.la

# 编译单元测试程序
$ eval libtool --tag=CC --mode=compile gcc -I./ (pkg-config --cflags glib-2.0) -std=c99 -c test.c
$ libtool --tag=CC --mode=link gcc libpm.la test.lo -o test

请原谅,为了方便,我使用了 libtool 来生成库文件。有时间的话我会单独介绍一下 libtool 的用法。如果现在就想了解一下 libtool,可阅读『使用 GNU Libtool 创建库』一文。

现在,可进行单元测试了,如下:

$ ./test
/PmList/pm_list_append: OK

运气不错,我们所创建的测试集 PmList 之中的测试案例 pm_list_append 通过了测试!

gtester

下面,我们增加一个有点恶搞的测试案例 ,改动后的 test.c 文件内容如下:

#include <glib.h>
#include <pmlist.h>

static void
pm_test_list_append(void)
{
    size_t N = 10;
    int numbers[N];
    PmList *list = pm_list_alloc();
    for (int i = 0; i < N; i++) {
        numbers[i] = i;
        pm_list_append(list, numbers + i);
    }

    int *_numbers = numbers;
    pm_list_foreach(list, it) {
        int *i = it->data;
        g_assert_cmpint(*i, ==, *(_numbers++));
    }
    g_assert_cmpint(pm_list_size(list), ==, N);

    pm_list_free(list);
}

static void
pm_test_list_blabla (void)
{
        g_assert (0 == 1);
}

int
main(int argc, char **argv)
{
        g_test_init(&argc, &argv, NULL);
        
        g_test_add_func ("/PmList/pm_list_blabla", pm_test_list_blabla);
        g_test_add_func("/PmList/pm_list_append", pm_test_list_append);

        return g_test_run();
}

新添加的测试案例为 pm_list_blabla。之所以说它是恶搞,因为它企图测试 0 == 1 是否成立,显然,这会导致单元测试程序崩溃。由于这个恶搞的 pm_test_list_blabla 案例先于那个严肃的 pm_list_append 案例,这意味着 g_test_run 函数在运行时会先测试前者,然后再测试后者。由于 pm_test_list_blabla 肯定会导致单元测试程序终止,所以 pm_list_apppend 案例永远也不会被测试。为了验证这一点,我们需要重新构建并运行 test 程序:

$ libtool --tag=CC --mode=compile gcc -I./ `pkg-config --cflags glib-2.0` -c test.c
$ libtool --tag=CC --mode=link gcc libpmbase.la test.lo -o test
$ ./test
/PmList/pm_list_blabla: **
ERROR:test.c:9:pm_test_list_blabla: assertion failed: (0 == 1)
Aborted

结果在预料之中!不过, GLib 测试框架提供了 gtester 工具。使用这个工具去调用测试程序,便可以穿越那个恶搞的 pm_test_list_blabla 案例,抵达 pm_test_list_append

gtester 的基本用法如下:

$ gtester ./test
TEST: ./test... (pid=8540)
**
ERROR:test.c:9:pm_test_list_blabla: assertion failed: (0 == 1)
GTester: last random seed: R02S94ea3ce4db54c6089021182eccf3561d
Terminated

观察上面命令的输出,虽然内容较直接运行 test 程序多了一些,但是依然未能穿越 pm_test_list_blabla 案例。因为我们没有用 -k 参数(--keep-going),加上这个参数,结果便会有变化:

$ gtester -k ./test
TEST: ./test... (pid=9686)
**
ERROR:test.c:32:pm_test_list_blabla: assertion failed (0 == 1): (0 == 1)
GTester: last random seed: R02S23c16f6c96bc24fb35095b61a758318a
(pid=9700)
FAIL: ./test

事实上,加上 -k 之后的 gtester 可以彻底的完成 test 程序中的所有测试案例,但是它的输出信息中只报告出错的案例。如果我们再添加 --verbose 参数,那么无论是出错的案例还是通过测试的案例皆可尽收眼底,如下:

$ gtester -k --verbose ./test
TEST: ./test... (pid=9730)
  /PmList/pm_list_blabla:                                              **
ERROR:test.c:32:pm_test_list_blabla: assertion failed (0 == 1): (0 == 1)
FAIL
GTester: last random seed: R02S08db1018cb6f0f04651da9869a610b65
(pid=9744)
  /PmList/pm_list_append:                                              OK
FAIL: ./test

如果你不习惯测试程序在终端中输出的测试结果,可能你认为那样过于原始,那么可以考虑向 gtester 工具提供像 -o=filename.xml 这样的参数,指示 gtester 工具将测试结果以 XML 格式写入 filename.xml 文件中,然后使用 gtester-report 工具将 filename.xml 文件转换为 filename.html 文件。例如:

$ gtester -k -o=test.xml ./test
$ gtester-report test.xml > test.html

使用 Web 浏览器打开 test.html 文件,

$ firefox test.html

就会看到类似下图所示的页面。

gtester-report

注意:事实上,在使用 gtester-report 时,它会出错。原因与修正方法见:https://bugs.launchpad.net/ubuntu/+source/glib2.0/+bug/1036260

秘密

可能你会有问,GLib 测试框架是如何做到穿越出错的测试案例的?

答案需要在 GLib 的源代码中寻找,如果你为不愿为此耗费气力,那么我大致说一下我对这个问题的理解。

g_test_add_func 这样的函数,它除了创建测试集和测试案例之外,还负责将测试案例添加到一个全局的链表中,这个链表的每个结点都表示一个测试案例。

当我们开启 gtester 工具的 -k 参数时,gtester 便会采用这样的策略运行测试程序:遍历测试案例链表,逐一运行测试案例,如果遇到无法通过测试的案例(这也意味着当前的测试程序会被操作系统终止),gtester 会将该案例所在的链表结点标记为忽略,并将链表信息保存在硬盘上,然后 gtester 会再次重启测试程序,并将上次测试无法通过的案例告诉 GLib 测试框架,让它从那个案例之后的案例开始测试。

夹具

GLib 测试框架的三个概念:测试集、测试案例和夹具,其中前两者在均已现身,唯独夹具似乎深不可测,它做什么用?

在 PmList 单元测试程序中包含着许多测试案例,其中每个案例的测试函数都需要一个确定的 PmList 容器实例(例如前文中存储整型指针的 PmList 容器实例)的支持方可进行测试。当我们将一个确定的 PmList 类型实例赋予某个测试函数之时,这便意味着是对这个测试函数的运行环境进行了固化,这就是夹具

GLib 测试模块提供了定义夹具的功能。下面我们继续对 test.c 文件进行改造,为其添加一个夹具,修改后的 test.c 内容如下:

#include <glib.h>
#include <pmlist.h>

typedef struct {
        PmList *list;
        int *numbers;
        int N;
} MyFixture;

static void
my_fixture_set_up(MyFixture *fixture, const void *user_data)
{
        fixture->N = 10;
        fixture->list = pm_list_alloc();
        fixture->numbers = g_slice_alloc(fixture->N * sizeof(int));
        for (int i = 0; i < fixture->N; i++) {
                fixture->numbers[i] = i;
                pm_list_append(fixture->list, fixture->numbers + i);
        }
}

static void
my_fixture_tear_down(MyFixture *fixture, const void *user_data)
{
        g_slice_free1(fixture->N * sizeof(int), fixture->numbers);
        pm_list_free(fixture->list);
}

static void
pm_test_list_append(MyFixture *fixture, const void *user_data)
{
        int *_numbers = fixture->numbers;
        pm_list_foreach(fixture->list, it) {
                int *i = it->data;
                g_assert_cmpint(*i, ==, *(_numbers++));
        }
        g_assert_cmpint(pm_list_size(fixture->list), ==, fixture->N);
}

int
main(int argc, char **argv)
{
        g_test_init(&argc, &argv, NULL);
        
        g_test_add("/PmList/pm_list_append",
                   MyFixture,
                   "用户数据,若没有,就是 NULL",
                   my_fixture_set_up,
                   pm_test_list_append,
                   my_fixture_tear_down);

        return g_test_run();
}

就是将 PmList 封装到自己定义的 MyFixture 结构中,然后为 MyFixture 结构提供构造函数 my_fixture_set_up 以及析构函数 my_fixture_tear_down,这两个函数分别象征着夹具的安装与拆除。

从数据的角度来看,夹具与测试函数的第一个参数都是 MyFixture。像是有一根管子将 my_fixture_setup, pm_test_list_append 以及 my_fixture_tear_down 等函数连接了起来,MyFixture 在这个管子里穿过……从范畴论的角度来看,夹具是一个单子,可参考『Kleisli 范畴』一文。

哪些函数值得测试?

在这篇文章中,可怜的 pm_list_append 被我揪出来作为重点考察对象,对它进行了各种测试,这并非是我的本意。之所以要虐待它,是因为它足够简单,我不想制造太复杂的问题背景,这不过是一篇介绍 GLib 单元测试框架的基本用法的文章。

那么,在实际的项目中,有哪些函数值得测试?

这个问题,应该没有标准答案的。编程是一门技术,但也可以很艺术。我只能告诉你,让测试活动覆盖 100% 的代码肯定是无法实现的,即使你食不厌精,脍不厌细,真的去测试了一切,但是测试代码本身的正确性依然没有被测试。

示例的源码文件

源码文件下载地址:http://pan.baidu.com/s/1o6vQkhs

参考文档

[1] Check 项目

[2] Cuit 项目

[3] Glib Testing 模块的参考手册

[4] GLib 测试框架的开发者的一封邮件

[5] 然后,他写了一篇指南

[6] 有人又写了一篇简单的指南


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。


« 上一篇
<译> 函子性
下一篇 »
理解 GNU Libtool