OneAPM-PHP探针是如何做单元测试的?


#1

背景


PHP探针2.4.0以来,稳定型有了很大的提升,很少在客户环境下崩溃。2.4.0以前的版本,比如2.3.12就常有客户反馈产品崩溃问题。产品质量的保证除了萌萌哒的QA的细心测试外,还有研发人员对提高单元测试覆盖率的努力。

目前PHP探针daemon部分C0:65%,C1:51%,当然在自动化测试这条路上,PHP探针含有很长的路要走。

TL;NR: 本文主要介绍PHP探针如何进行单元测试及在编写单测过程中遇到的问题和解决方法。

框架选择


C语言(PHP探针使用C语言编写)单元测试框架有很多,但调研阶段只看过CUnit的单测例子,如下所示,完整代码在这里

int main()
{
   CU_pSuite pSuite = NULL;

   /* 初始化 */
   if (CUE_SUCCESS != CU_initialize_registry())
      return CU_get_error();

   pSuite = CU_add_suite("Suite_1", init_suite1, clean_suite1);
   if (NULL == pSuite) {
      CU_cleanup_registry();
      return CU_get_error();
   }

   /* 注册单元测试函数 */
   if ((NULL == CU_add_test(pSuite, "test of fprintf()", testFPRINTF)) ||
       (NULL == CU_add_test(pSuite, "test of fread()", testFREAD)))
   {
      CU_cleanup_registry();
      return CU_get_error();
   }

   /* 运行单测 */
   CU_basic_set_mode(CU_BRM_VERBOSE);
   CU_basic_run_tests();
   CU_cleanup_registry();
   return CU_get_error();
}

以前看到过java的单测例子,只需要在函数头上添加一个@Test的标签,JUnit会自动执行该单测函数
而上面的CUnit却还有很多冗余的工作要做:

* 写main函数
* 初始化,清理
* 注册编写的单测函数

第二个看到的C单测框架是Criterion,当时看到其主页上编写单测并运行的动画,感觉这个框架很不错,上面说的三个冗余操作都由框架完成,功能也挺丰富,就决定在项目中试用,本文使用的2.1.0版本。

Criterion简介


支持如下功能:

* 测试函数自动注册
* 框架提供入口函数
* fork子进程,隔离测试
* 支持对程序exit码的测试
* 其他的。。。具体看github的readme

一个简单示例

#include <criterion/criterion.h>
Test(simple, test)
{
    cr_assert(1);
}

编译执行

gcc -o test test.c -lcriterion && ./test

图片来自Criterion的github主页

有关mock


  1. glibc中的socket为例,一个简化的mock如下(需要动态链接glibc):

int (*real_socket)(int domain, int type, int protocol);
int socket(int domain, int type, int protocol)
{
    if (mock) {
        errno = EINVAL;
        return -1;
    }

    if (!real_socket) {
        real_socket = dlsym(RTLD_NEXT, "socket");
    }

    return real_socket(domain, type, protocol);
}

通过设置变量mock决定是否使用。

2 PHP探针中需要跟服务器通信的部分,本文实现了一个简化的服务器线程,与单元测试运行在同一进程空间以方便测试。

PHP探针与单测代码集成编译


编写完文件A.c的函数foo的单测后,可以按以下方式编译:

1. 找出A.c中的所有符号依赖的所有其他源文件
2. 递归找出其他源文件依赖的源文.   
3. 将所有源文件和单测文件编译链接成可执行文件

这种方法要对每个需要测试的文件都要找一遍依赖,比较繁琐。
使用ld--gc-sections选项可以仅寻找被测函数foo的依赖,但也需要手动进行。

为了简化单测的编译链接过程,本文采用了如下方式:

1. 按编译可执行文件的方式编译项目中所有源文件,生成`.o`文件。
2. 使用`strip`从带`main`函数的`.o`文件中去掉`main`(Criterion自带入口函数)。
3. 将项目的`.o`与单测的`.o`链接成可执行文件。

调试出错的单测


由于使用该框架时还不支持单用例执行,而且每个单测是在单独的子进程中,本文使用了一种比较笨的方法:

1. 在出错的单元测试函数开头添加kill(getpid(), SIGSTOP)
2. 编译运行,然后使用gdb -p <pid>跟踪调试

说明:设置set-follow-fork-modechild也可以调试,但是需要知道出错的是第几个child才能设置。

好消息是最新的2.2.0版本,已经支持单个测试用例执行模式,调试就简单多了。

写单元测试的一些感受


  1. PHP探针是在发了很多版后才开始写单测,补债的过程很痛苦;
  2. 写代码时要提高函数的可测试性:本文为PHP探针写单测时也是边重构边写单测,因为已有代码编写时没有考虑可测试性;

举几个写单侧过程中遇到的例子
1. 函数输入要能够从外部传入:

比如某个函数来自一个特定路径的文件,而该文件名是以宏的形式存在,这时这个函数就很难去测试
好的写法是将文件名以参数传入,这样就可以针对不同的文件内容做测试

2 有关static函数

static函数一般是内部函数,不在文件外访问,想要验证某个static函数bar就只能通过调用了bar的非static函数foo进行
但foo函数可能很难验证结果的正确性,比如:
static void bar(some_parameter *arg)
{
    ....
}
void foo()
{
    some_parameter *arg;

    arg = do_some_init();
    bar(arg);
    destroy_everything(arg);

    return;
}
函数foo执行结束后销毁了内部状态,因此无法验证bar是否正确执行。
本文针对这类情况的做法是去掉static属性。

Criterion自动注册单测函数的原理


Criterion对每种类型的单测都会在最终的ELF文件生成一个section,如下以cr_开头的段,设置编译器将单测函数放入指定的段,然后在运行时读取段中的内容。

localhost $ readelf -S unit_test
....
  [28] cr_tst            PROGBITS         00000000008abc60  002abc60
       0000000000000048  0000000000000000  WA       0     0     8
  [29] cr_sts            PROGBITS         00000000008abca8  002abca8
       0000000000000008  0000000000000000  WA       0     0     8
....