仓库源文

.. Kenneth Lee 版权所有 2016-2020

:Authors: Kenneth Lee :Version: 1.0

从单元测试理解软件


介绍

前面谈论的都是比较高端的战略型话题,这篇我们来写一些比较入门级别的东西吧。把这 个东西放到构架设计这个专栏里,是因为其实很多软件工程师都不知道怎么做单元测试。 而不能正确理解如何做单元测试,对软件构架的理解就不可能深刻,所以,对于入门级的 无论是架构师还是程序员,我们都来理解一下软件开发的基本工艺,学习一下单元测试吧 。

先看一个例子,下面是Linux Kernel 4.6, kernel/workqueue.c的其中一段:::

    static struct worker *create_worker(struct worker_pool *pool)
    {
            struct worker *worker = NULL;
            int id = -1;
            char id_buf[16];

            /* ID is needed to determine kthread name */
            id = ida_simple_get(&pool->worker_ida, 0, 0, GFP_KERNEL);
            if (id < 0)
                    goto fail;

            worker = alloc_worker(pool->node);
            if (!worker)
                    goto fail;

            worker->pool = pool;
            worker->id = id;

            if (pool->cpu >= 0)
                    snprintf(id_buf, sizeof(id_buf), "%d:%d%s", pool->cpu, id,
                             pool->attrs->nice < 0  ? "H" : "");
            else
                    snprintf(id_buf, sizeof(id_buf), "u%d:%d", pool->id, id);

            worker->task = kthread_create_on_node(worker_thread, worker, pool->node,
                                                  "kworker/%s", id_buf);
            if (IS_ERR(worker->task))
                    goto fail;

            set_user_nice(worker->task, pool->attrs->nice);
            kthread_bind_mask(worker->task, pool->attrs->cpumask);

            /* successful, attach the worker to the pool */
            worker_attach_to_pool(worker, pool);

            /* start the newly created worker */
            spin_lock_irq(&pool->lock);
            worker->pool->nr_workers++;
            worker_enter_idle(worker);
            wake_up_process(worker->task);
            spin_unlock_irq(&pool->lock);

            return worker;

    fail:
            if (id >= 0)
                    ida_simple_remove(&pool->worker_ida, id);
            kfree(worker);
            return NULL;
    }

我是拿这一段来做例子,放到这个blog中的只是整个.c的一部分,但我们测试的时候显然 是用整个.c来测试的,放这个片段在这里,是为了我们后面讨论的方便,读者只要看这个 blog,不需要查对应的代码。

我对这个文件进行测试的方法是这样的:写一个workqueue.ut.c,这样:::

    //workqueue.ut.c: ut stub for kernel/workqueue.c
    #include "ut.h"

    int testcase=0;
    //stubs for type definition
    struct work {}; 
    ...

    //stubs for functions
    struct worker w;
    static struct worker *alloc_worker(int node){
      if(testcase==1) return NULL;
      else if(testcase==2) {
        ut_assert(node==3, "wrong input of node(%d), should be 3", node);
        return &w;
      else ut_assert(0, "wrong testcase %d", testcase);
    }

    #include "workqueue.c"

    void test_case1() {
      struct worker wk;
      testcase=1;
      wk = create_worker(NULL);
      ut_assert(wk==NULL);
    }
    ...
    int main(void) {
      test_case1();
      test_case2();
      ...
    }

(这个代码我没有调试,我先说原理,以后有空再调试)

写成这样,基本上读者应该可以理解,我说的单元测试是什么了。我提供的方法几乎没有 任何平台依赖,这个Linux内核的程序,完全可以拿到Windows上来测试,没有任何问题, 如果你觉得有问题,不妨发问,很可能只是你有某个细节没有掌握。

单元测试的目的,是测试本单元(就是本C程序,必要的时候,也不包括头文件)的所有可 以执行的流程,都在测试范围内。如果你有C语言基础,立即可以用,读者不妨试一试。 ut.h的实现你猜都能猜到, 我这里有一个Linux平台上的实现供参考:::

    /**
     * unit testing helper file, used only under linux with glibc
     */
    #include <stdio.h>
    #include <stdlib.h>
    #include <execinfo.h>
    #include <stdarg.h>
    #include <setjmp.h>

    /**** ut_assert ****/
    #define ut_assert(cond) ut_assert_func(__FILE__, __LINE__, !!(cond), "")
    #define ut_assert_str(cond, fmt, ...) ut_assert_func(__FILE__, __LINE__, !!(cond), fmt, ##__VA_ARGS__)

    #ifdef UT_DUMPSTACK
    #define ut_dumpstack() dumpstack()
    #ifndef DUMP_DEEP
    #define DUMP_DEEP 10
    #endif
    void dumpstack(void) {
            void * arr[DUMP_DEEP];
            int l, i;
            l = backtrace(arr, DUMP_DEEP);
            fprintf(stderr, "dump stack: \n");
            for(i=0; i<l; i++) {
                    fprintf(stderr, "0x%lx\n", (unsigned long)arr[i]);
            }
    }
    #else
    #define ut_dumpstack()
    #endif

    void ut_assert_func(char * f, int line, int cond, const char *fmt, ...) {
            va_list args;

            va_start(args, fmt);
            if(!cond) {
                    printf("testfail at %s:%i: ", f, line);
                    vprintf(fmt, args);
                    printf("\n");
                    ut_dumpstack();
                    abort();
            }
            va_end(args);
    }

    /**** testcase and broken jump ****/
    void default_broken(int val) {
            printf("broken from test (val=%d)\n", val);
    }

    //int testcase = 0;
    jmp_buf jmpenv;
    void (*broken)(int val) = default_broken;

    static inline void testj(void (*test_func)(void)) {
            if(setjmp(jmpenv)) {
                    broken(-1);
            }else {
                    test_func();
            }
    }
    #define ut_break(val) longjmp(jmpenv, val)

一些基本要领

现在来介绍一些基本要领。首先,我的习惯是在工程之外建UT工程,比如你有一个工程在 abc目录下,里面有aaa.c, bbb.c, ccc.c, Makefile乃至http://configure.in等,这些东 西我都不想影响,我可以在abc之外建一个abc.ut的目录来放我的单元测试代码,也可以在 abc之内,放一个ut的目录,这样,大部分情况下,UT是不影响原来的工程的,这一点很重 要,一个正规的代码写出来,首先是功能,然后你要加性能优化,然后加可靠性的补充, 然后日志特性,然后加现场可测试性补充,然后加现场可维护性特性,每一个商业级别能 力的增加,都是对架构设计的一个沉重负担,UT这种级别的测试,就不要再加进来添乱了 。这是第一。

第二,单元测试是测试你的本.c的代码,有一个重要要领是,不要尝试在数据结构上建立 多余关联。前面我已经说过了,很大程度上,我们不测试所包含的头文件(特别是系统头 文件)。所以,比如你包含了<device.h>,你没有必要真的包含它,你写个空文件让你的 .c包含就好了,如果你用到struct device,你也没有必要把device.h中的定义拷贝进来, 你在你的xxx.ut.c中增加一个空定义就好了:struct device {}; 然后你的程序中用到其 中某个成员,你就增加那个成员的定义即可。这种方法可以有效隔离你的代码和其他模块 。

第三,我们要正确理解单元测试的目的,单元测试的目的是测试你写下来的每行代码本身 的逻辑组织是否和你的预期一致。

这里说到两个非常重要的概念,第一个是“你写下的每行代码本身”,上面被测试的那个程 序,调用了一个函数wake_up_process(), 如果那个函数在你写的那个被测试的.c中,那个 属于“你写下的每行代码本身”,如果不是,它就不是,它仅仅表示你调用了一个函数,它 的工作是否正常,不是你单元测试考虑的范围,你可以对它有预期,以此来修正你自己的 行为,但你不是在测试它的行为是否正确。

第二个概念是“你的预期”,还是用这个wake_up_process来说,你写程序的时候,对这个函 数是有期望的,但它不一定符合你的期望,而我们前面说过了,你测试的是你怎么办,不 是测试“别人应该怎么样”,所以,你测试“你的预期”,而不是测试那个函数的行为。

这就涉及到单元测试和集成测试的区别的。单元测试测试的是你的那个“单元”,不是你的 单元和其他单元发生作用的时候怎么样,前者是单元测试,后者是集成测试。单元测试是 保证软件质量的第一步,在简单的系统上,我们甚至不需要做集成测试,但单元测试是不 应该被省略的(特别是对于长期使用的商用部件)。

如果我们使用一些比较新的库,即使有手册,这些库函数的行为也不见得和手册的定义一 致。所以,验证一下某些函数是否和设想一致,这是很多程序员工作的常态。我不反对做 这样的事情,我自己也做这样的事情。但请注意,那个不是单元测试。你还是要回到单元 测试的目标来。

单元测试的目标是测试逻辑是否按预期那样发生作用,“预期”来自输入和输出,一个函数 的输入和输出到底指什么?好好想想这个问题,其实不是那么容易一眼就注意到的:

输入:全局变量,入口变量,返回值,所调用的其他函数的返回值

输出:全局变量,入口索引变量, 所调用的其他函数的输入参数

从这个角度来重新看待一个函数,是不是觉得它特像一个有很多入口出口的水管系统?借 网上的一张图(侵删)来比喻:

    .. figure:: _static/管道系统.jpg

你要做的是,在不同的入口灌入不同的水,然后在不同的出口上判断这个输出和你的预期 是否一致。

我写程序(大部分是平台级软件,比如bootload,OS,驱动,中间件等。但如果是UI,可 能我会省略其中一些步骤)的过程是这样的:

  1. 先写基础逻辑

  2. 进行逻辑优化

  3. 在所有逻辑不straightforward的,或者对不太可靠的库的输入有要求的地方,一概加 上ASSERT()

  4. 在所有在运行中不会引起性能瓶颈的执行分支上都加上性能统计参数

  5. 单元测试

  6. 集成测试(通常集成到单机一级)

  7. 系统测试,战地测试(这时重点关注所有的性能统计参数,看现网条件下,程序是否按 预期运作)

  8. 下一个开发循环,复用上一次的单元测试用例

通常我的程序在单元测试后,逻辑错误几乎为0,剩下都是同步,性能一级的错误了,而且 ,我可以很得意的说,我的程序在数百万乃至上千万个节点上运行,能反馈回来给我的错 误也是少之又少的。

这是商业产品开发的样子,当然,我知道很多互联网DevOps常常不是这样,但他们很多软 件工作在整个网络的边缘,能造成的破坏有限,只要你向网络的中心靠拢,越来越多的用 户依赖你的逻辑,你的程序就必须越加的稳重。我前面说,软件开发的效率不过是每天 20-50行,有人跟我说这不可思议。这个例子可能能让您找到一点感觉了。通常单元测试的 代码量是最终代码量的2-3倍,所以20行代码,实际是要写80行的,加上分析,文档,性能 优化的工作量,50行实在是太看得起您了:)

回归正题,所以我们做单元测试,重点就是要构造一些用例,这些用例在前面提到的4个点 上制造不同的输入,然后在所有的输出点上加上ut_assert(),看输出是否符合断言,如果 不符合就要进去看到底是什么逻辑出了问题了。

单元测试到底是白盒测试还是黑盒测试也是个常常引起讨论的问题,要讨论这个问题又得 精细化这两个定义了。这里不想展开这个讨论,我只泛泛说说我的经验:我认为我们必须 在出入口之外,把函数看作是黑盒,但如果你注意到我对出入口的定义,你就会发现这种 黑盒已经有点接近白盒了。我这里只能说,你一定不能关注到函数的实现细节,你必须从 实现细节上抽离,回到你要解决的问题上,只想“对于什么样的输入,你认为一定会输出什 么”,这样你才不会写那种“本来就是这个结果”的测试用例来。如前所述,单元测试照理说 是可以过滤掉大部分的逻辑错误的,如果你发现这样的逻辑错误最后在后面的过程中被发 现,那你就要反省一下当时为什么没有构造出这样的逻辑来了。

对于用例的构建,就真的是个经验问题了,我会按如下方向来考虑测试用例:

  1. 构建随机输入,先判断是否出现比如内存越界等问题

  2. 验证边界,极限数据是否有可能错

  3. 双算法校验,这个通常用于非常关键的算法,写两个算法(通常高效的算法用于工作系 统,低效但可靠的算法用于测试),然后用大量的数据进行轰击,看两个算法的输出是 否一致。

无论用哪种方法吧,我们一般无法覆盖所有的情况,所以,单元测试始终是个动脑的问题 。这也是为什么我更愿意用这种貌似很原始的方法来进行测试。很多商用级别的单元测试 工具确实好像很省事,但阻碍了我实施我最关注的问题,所以我宁愿用这样的方法。不过 ,覆盖率工具还是很有用:在你测试完成后,用覆盖率工具看看你的测试似乎覆盖了所有 的分支,这能有效帮助你判断你的测试是否充分了。我建议是一般情况语句覆盖应该可以 达到100%,分支覆盖要达到70%+。

可能不少人不知道,gcc是自带覆盖率工具的,有兴趣的可以查一下--coverage参数的用法 。

这里介绍的测试方法,看起来工作量是挺大的,但我要再说一句,以我的经验,在复杂系 统中,特别是那种大流量,多节点,多核的系统中,这种测试其实能大大缩短开发工期。 因为这样写出来的代码,你自己是很有信心的,它们如同一个你自己亲手打磨和在各种场 合中都试验过的管道系统,在使用的时候,你是很有信心的,当你在集成和系统测试的时 候对你的程序很有信心,你就可以把问题的可能位置收缩在其他问题上,这会大大加快你 定位问题的速度的。

理解程序语义和自然语义

下面我们来讨论一下, 下面这个函数的桩怎么打:::

    struct task_struct * kthread_create_on_node (   int (*threadfn) (void *data),
            void * data,
            int node,
            const char namefmt[],
            ...);

根据我的经验,不少刚接触单元测试的人会不断纠结这个问题:怎么才能模拟一个线程来 执行threadfn呢?但也许我们没有注意到,你已经被自然语义左右了,如果这个函数修改 成这样:::

    type1 function1 (type2 var1,
            type3 var2,
            type4 var3,
            type5 var4,
            ...);

你还有上面的问题吗?

这就是我上面反复提到的程序语义和自然语义的区分问题。构架设计关注的是自然语义, 让计算机理解人的要求,而单元测试,关注的是程序语义,是看计算机是否按设计要求的 流程运作。单元测试的时候,我们要彻底把程序看作一个“管道”系统,而不去关心这个管 道中到底流的是水,沙子,还是卫生巾。

所以,你测试这个流程,没有必要把#typedef int ( type2)(void )看得和#typedef int type4有什么不一样,都是一个变量而已。把思路聚焦在纯粹的流程上,你就会很容易 找到变量的范围有可能是哪些,边界在哪里,if, else, while等机制在什么分界点上会分 流。然后你才不会被自然语义阻碍了你对“范围”的判断。程序出错的时候,通常发生在你 预想的自然语义之外,但程序如果发生了和程序语义相违背的情况,大部分都是编译器错 误了,这种可能性是很低的。

我把这个文档放在架构设计的专栏中来描述,主要是要想表达这个概念。我们必须能从这 个角度来看程序,才容易从很多独立的角度来看架构设计,把独立的逻辑能单独拿出来看 。没有这种单元测试经验的人,你跟他说多少次某个设计逻辑只是针对某个方面的,他都 无法理解。

独立理解程序的动力和传动机构

单元测试还会让我们清晰地区分一个程序的动力源和传动机构。我们前面已经看到了,函 数其实是一个管道系统,不同的水倒进来,会被分流到不同的位置。它本身是不会动的。 如果从机械的角度,函数是一个刚性部件,没有动力的时候,这个部件不会动。

把数据灌进这个管道系统,这是动力。我们做单元测试的时候,是为每个测试用例提供一 个动力源,单独看函数在这个动力下,是否按正常的方式运转。

当这些函数组织成程序,动力源就是线程,由于函数是刚体,多个动力源就不能作用在同 一个函数实例上面(多线程执行同一个函数是执行这个函数的多个实例,因为他们的局部 变量是不同),否则就会造成速率匹配问题。通常要进行速率匹配,我们需要非刚体,对 程序来说,常见的是队列。

但除了队列,函数内部是有可能产生刚性匹配的,那就是锁和全局变量了。说了这么多废 话,就是要讨论到底我们要怎么进行锁的测试了。如果纯按刚才的逻辑,忘掉锁的自然语 义,锁就是个函数调用,我们最多就是测试一下在各种情形下,上锁和解锁是否对称。但 我们是否有可能初步验证一下多个全局变量在组合变化之下是否有意向不到的组合错误呢 ?

这确实是可能的,因为可以认为创建多个线程,对流程进行组合。但以我的经验这种测试 发现问题的机会很低,远不如通过前面谈到的统计系统有效,我认为是不值得的。之所有 单独把这个事情拿出来谈,不是因为它对单元测试重要,而是提醒读者,注意这种传动系 统和动力源的关系,对你设置统计项是很重要的,这也是程序可测试性设计的一部分。

其他技巧补充

这里列出一些常见的技巧,如果读者有其他问题,可以提问,我再补充进来:

  1. memcpy,printf等如何打桩

gcc有一个特性不知道大家是否知道,memcpy这些函数都是weak符号,就是说,你在你的 xxx.ut.c中定义了这个函数,就可以覆盖系统符号,所以,后面的就不用我说了吧。你完 全可以按需要决定是否给这些函数打桩的,正常情况我都不需要给这些函数打桩的

  1. 跳出死循环

如果函数里面是死循环测试就退不出来了,比如下面这种情况:::

    while(1) {
      e=get_event();
      handle_event(e);
    }

这种情况你需要longjmp库(这应该是标准POSIX库吧),从get_event()和handle_event() 之类的桩里面跳出来就好了。我前面的ut.h中有例子,可以参考。

如果这个while里面没有函数怎么办?这还有一个终极秘籍,C语言有#ifdef DEBUG的好吧 ,这是最后的手段了,什么时候你有问题解决不了,这个方法你什么都可以解决了吧:)

  1. 分配和释放内存怎么验

这个我一般有两个办法,一种是直接使用系统的malloc/free函数,另一种是打桩,返回一 个静态变量,这种方案有助于在离开被测函数的时候可以验证一下这些变量是否正确。

  1. 隔离每个测试用例

测试用例和普通程序的写法是不同的,普通程序要把重复的逻辑合并,测试用例应该让每 个用例独立,尽量不要写复用的代码,除非是一些helper函数(比如每次制造用例都要把 某个结构按特定的方式初始化)。我的经验是放一个全局变量testcase,每个用例前对它 赋值,这样在各个桩里面就可以通过这个全局变量给不同的值了。

其他语言的问题

这里介绍的是C的方法,但一样的方法一样可以用于其他语言,比如Java,你可以放在静态 内部类来实现按最大权限访问所有的函数,然后你就可以玩出一样的花样来了。这个方法 的核心是你怎么看一个程序,而不是你用什么语言。

脚本语言比如bash,倒真是没有什么办法。如果读者有办法请告诉我。

使用工具

很多单元测试工具都很贵,简单的如JUnit,只是个很简单的框架,用不用都差不多,真正 好用的都是商用级别的,老实说,不少还是挺好用的,比如有些可以自动产生所有的桩, 然后用随机数据来验有没有内存越界的情况。我这里不给他们做广告,但我这里想说的是 :如果你不理解我这里给你介绍的基本原理,你用了也是走过场,测不出几个问题来。

所以,我赞成使用工具,但如果你要学习,我是建议先用这里的方法入门。