标签 LCUT 下的文章

CBehave – 一个C语言行为驱动开发框架

Behaviour-Driven Development,即行为驱动开发在业界早已不是什么新鲜玩意了。我之前也略有了解,不过一直没有"深入钻研"。直到今年年初InfoQ的几篇有关BDD的文章才让我对BDD有了更多的认识。与TDD一样,C语言在BDD领域依旧是一个"后进分子",在多数主流语言(Java,C#,Ruby等)都已经拥有比较成熟的BDD框架(如JBehaveSpecFlowCucumber)的今天,C语言却似乎仅有一款BDD框架-CSpec可用。于是年初的时候我就把设计和实现一个用于C语言的行为驱动开发框架加入到我今年的ToDoList中了。

在确定好目标的同时,我也给这款框架命名为CBehave(模仿JBehave),并在Google Code上建立了CBehave的托管项目。但人的时间和精力总是有限的,直到8月中旬我才开始着手进行这个框架的设计和实现。设计和实现一款给程序员使用的工具,这本身就是一件让人兴奋的事情。我先是通过DanNorth的博文"Introducing BDD"(其中译版在这里)了解了BDD的"诞生历程",然后又广泛地了解了一下其他语言的BDD框架。对于CSpec这一目前唯一的C语言BDD框架,我并不想给予过多评价,不过总体来说和其他语言的BDD框架相比,CSpec有些简陋,应该说还无法很好的支持BDD中一些核心思想的表达,并且目前它还不支持Mock。这些都坚定了我重新实现一个C语言BDD框架的决心,起码我不完全是"重新发明轮子"^_^。

作为后来者,CBehave的设计参考了诸多现有的主流BDD框架,其中直接灵感来源于Cucumber,不过由于C语言静态编译语言的本质,CBehave与Cucumber在大多地方也只是形似而已。作为一篇CBehave的介绍性文章,这里列举一些CBehave的主要特点:

首先,CBehave借鉴Cucumber的设计采用Feature + Scenario结合的方式来描述功能需求(DanNorth: 需求也是行为),并且在每个Scenario内部采用BDD的经典的GIVEN-WHEN-THEN结构描述行为的验收标准(acceptance criteria)。

这里给出一个总体的行为描述模板:

FEATURE #1
    SCENARIO #1
        GIVEN
            … …
        WHEN
            … …
        THEN
            … …

    SCENARIO #2
        GIVEN
            … …
        WHEN
            … …
        THEN
            … …

    … …

FEATURE #2
… … 

FEATURE #n

原则上FEATURE之间是相互隔离的;FEATURE内部的多个Scenario之间在代码定义和执行时也是相互隔离,互不干扰的。这是一个使用该框架的基本约束。不过这就好比建议性锁,全靠使用时的自觉,否则很容易造成框架运行出错。

下面是一个真实的使用CBehave对strstr函数进行测试的例子(代码片断,完整例子参见源码cbehave/src/example/string_test.c):

FEATURE(1, "strstr")
    SCENARIO("The strstr finds the first occurrence of the substring in the source string")

        GIVEN("A source string: [Lionel Messi is a great football player]")
            char *str = "Lionel Messi is a great football player";
        GIVEN_END

        WHEN("we use strstr to find the first occurrence of [football]")
            char *p = strstr(str, "football");
        WHEN_END

        THEN("We should get the string: [football player]")
            SHOULD_STR_EQUAL(p, "football player");
        THEN_END

    SCENARIO_END

    SCENARIO("If strstr could not find the first occurrence of the substring, it will return NULL")

        GIVEN("A source string: FC Barcelona is a great football club.")
            char *str = "FC Barcelona is a great football club";
        GIVEN_END

        WHEN("we use strstr to find the first occurrence of [AC Milan]")
            char *p = strstr(buf, "AC Milan");
        WHEN_END

        THEN("We should get no string but a NULL")
            SHOULD_STR_EQUAL(p, NULL);
        THEN_END
    SCENARIO_END
FEATURE_END

int main() {
    cbehave_feature strstr_features[] = {
        {feature_idx(1)},
    };

    return cbehave_runner("Strstr Features are as belows:", strstr_features);
}

编译运行这个测试,我们会得到如下结果(节选):

Strstr Features are as belows:

Feature: strstr
 Scenario: The strstr finds the first occurrence of the substring in the source string
  Given: A source string: Lionel Messi is a great football player
  When: we use strstr to find the first occurrence of [football]
  Then: We should get the string: [football player]
 Scenario: If strstr could not find the first occurrence of the substring, it will return NULL.
  Given: A source string: FC Barcelona is a great football club.
  When: we use strstr to find the first occurrence of [AC Milan]
  Then: We should get no string but a NULL

Summary:
 total features: [1]
 failed features: [0]
 total scenarios: [2]
 failed scenarios: [0]

CBehave将strstr的行为原汁原味地输出到最终的测试结果中,与xUnit等框架相比,这确是一个进步,我们在获知测试结果的同时,还依稀中看到了这个特性的需求描述,前提是你要给出一个很好很精确的描述,但这已经不是框架可以帮助你做的了^_^。另外即使你的测试失败了,你甚至可以不通过错误提示中的源码文件名和行号信息也可以快速定位到错误的位置所在。因为错误周围是有足够的上下文信息的。

其次,CBehave支持mock。CBehave中mock的实现完全参考了我之前设计的单元测试框架LCUT,下面是一个简单的例子(片断,完整代码参加cbehave/src/example/product_database_test.c):

FEATURE(1, "Get the total count of employees")
    SCENARIO("Get the total count of employees")
        GIVEN("The db connection is ready and there are 5 employees in total");
            CBEHAVE_RETV_RETURN(connect_to_database, 0×1234);
            CBEHAVE_ARG_RETURN(table_row_count, 5);
            CBEHAVE_RETV_RETURN(table_row_count, 0);
        GIVEN_END

        WHEN("We call function: get_total_count_of_employee");
            int count = get_total_count_of_employee();
        WHEN_END

        THEN("The total count of employees we read from db should be 5")
            SHOULD_INT_EQUAL(count, 5);
        THEN_END

    SCENARIO_END
FEATURE_END

最后,CBeahve还支持多种SHOULD_XX宏,并且可根据需要灵活添加。目前已支持整型、字符串以及布尔类型的判定。

关于CBehave的实现历程这里也简单说一下。

首先需要确定CBehave的"长相",也就是CBehave采用的行为描述模板是啥样子的。这块儿确是花了我不少的时间,查看各种资料以及研究其他框架的设计,最终选择了Feature+Scenario以及用Given-When-Then来描述行为的"文档模板"。

其次,有了"文档模板"后如何将其转换为可执行的代码实体?这块也费了我不少脑细胞。思前想后,最终设计是将Feature转换成一个可运行的实体-函数。另外我在函数中使用{}来物理划分Scenario,{}可以隔离变量的可见性和作用域,已达到多个Scenarios定义和执行互不干扰的目的。

上面的标准文档结果中的一个FEATURE宏展开后的样子大致是这样的:

static void cbehave_feature_n(void *_state) {
    cbehave_state _old_state;
    cbehave_feature_entry(…, &_old_state, _state);

    {
        /* Scenario #1 */
        int _scenario_state = 0; \
        cbehave_scenario_entry(x, _state);

        … …
        cbehave_scenario_exit(&_scenario_state, _state);
    }

    {
        /* Scenario #2 */

    }

    … …
    {
        /* Scenario #n */

    }

_feature_over: \
    cbehave_feature_exit(…);
}

关于feature函数的命名,我考虑了很长时间,由于从外界输入的信息无法约束,这里我引入了Feature序号,并同时将序号作为Feature函数名的一部分。例如:
FEATURE(10, "fopen features")
展开后的feature函数名就是cbehave_feature_10,这样做对用户也有一定约束,但约束较小,只需CBehave用户保证各个Feature的序号不同即可。

在使用CBehave时,很可能出现另外一个问题:那就是测试代码在GIVEN或WHEN区段中依赖的一些资源申请或其他代码的初始化可能失败或出现异常。遇到这种情况时,用户多选择return或exit。但一旦用户这样做,CBehave就无法统计和输出测试失败情况或统计的不够准确了。为了尽量保证CBehave统计的精确性,CBehave提供了一个宏FEATURE_RETURN供用户使用。FEATURE_RETURN将控制权转移到Feature函数的末尾,也就是上面宏展开末尾的哪个_feature_over跳转标识符处,这样Feature有机会把这一错误情况记录下来。另外还可以保证其他Feature测试的继续运行。这里举个简单例子(完整代码参见cbehave/src/example/text_editor_test.c):

FEATURE(1, "Text Editor – Open Exsited File")
    SCENARIO("Open an Exsited File and write something to it")

        GIVEN("A file named foo.txt")
            FILE *fp = NULL;
            char *buf = "Hello Cbehave!";
        GIVEN_END

        WHEN("we open the file and write something to it")
            fp = fopen("foo.txt", "r+");
            if (!fp)
                FEATURE_RETURN(errno);
        WHEN_END

        THEN("We should see [Hello Cbehave] has been written into foo.txt")
            if (fp)
                fclose(fp);
        THEN_END
    SCENARIO_END
FEATURE_END

例子中如果fopen打开foo.txt失败,代码中调用了FEATURE_RETURN来应对这一情况,而不是直接调用return或exit。

最后,与LCUT不同,Cbehave会尝试运行完所有Feature的测试,而不是遇到测试错误就停止运行。

因为之前有过LCUT单元测试框架的设计和实现经验,这次CBehave框架的设计和实现就相对容易了些。CBehave的设计用了一天时间,上周末两天"百忙"中抽空完成了编码和测试,目前已提供了cbehave-0.1.0-beta版供下载体验,欢迎大家提出你的宝贵意见和建议^_^,更多关于CBehave使用方面的细节请参考CBehave用户指南(http://code.google.com/p/cbehave/wiki/CBehave_User_Guide_cn)。

BTW,我并不是一个纯粹的TDDers。我个人认为完全采用TDD或是BDD还是有一定局限的。是否采用这些方式进行开发还要视产品(或项目)的时间、质量、人员能力等诸多制约因素而定。个人推断国内的C程序员多普遍缺乏采用框架进行单元测试的意识,BDD或TDD的推广还是任重道远的。

让BuildBot服务于多个项目

多数公司不会仅有一个项目,当你为一个项目引入持续集成实践后,其他项目就会接踵而来。这时你会重新考量BuildBot,考虑如何让BuildBot可以服务于多个项目。

如果你有足够的主机资源和人力资源,那为每个项目单独搭建一套CI环境是再好不过的了,每个项目都有专人维护CI环境,各个项目的配置互不干扰。不过对于一些公司来说,这显然有些浪费,BuildBot Master的资源消耗是不大的,我们完全可以使用一套BuildBot Master来服务于多个项目,至少BuildBot是可以支持这样做的。

CI环境中,我们首要关注的就是源码库。多个项目可能各自使用单独的源码库,也可能共享一个源码库并通过目录隔离和识别。无论怎样,我们都可以通过BuildBot Master的配置来满足我们的要求。

如果说多个项目共享一个源码库或是一个项目下的多个子系统放在一个源码库中,这时我们在配置change_source时指定一个变更监测器即可,这个监测器的监测范围从源码库的根路径开始。以Subversion源码库为例,我们可以这样来配置:

c['change_source'] = [SVNPoller("svn://10.0.0.1:3000",
                                svnuser='tony',
                                svnpasswd='tony',
                                pollinterval=60,
                                split_file=change_path_split)]

我们通过change_path_split来拆分变更文件的路径,假设SVN库结构是这样的:
svn://10.0.0.1:3000
       – foo_proj
           – trunk
               – main/main.c
           – branches
           – tags
       – bar_proj
           – trunk
               – main/main.c
           – branches
           – tags

我们可以这样来实现change_path_split:

def change_path_split(path):
        pieces = path.split('/')
        if pieces[0] == ‘foo_proj’ and pieces[1] == 'trunk':
            return ('foo_proj/trunk', '/'.join(pieces[2:]))
        elif pieces[0] == ‘bar_proj’ and pieces[1] == 'trunk':
            return ('bar_proj/trunk', '/'.join(pieces[2:]))
        else:
            return None

不同项目下的文件变更,会导致change_path_split返回不同的值,而change_path_split返回值会被用于匹配不同的Scheduler:

c['schedulers'].append(Scheduler(name="foo-ci-plan",
                                 branch='foo_proj/trunk',
                                 treeStableTimer=5,
                                 builderNames=["foo-redhat-builder", "foo-x86-solaris-builder"])

c['schedulers'].append(Scheduler(name="bar-ci-plan",
                                 branch='bar_proj/trunk',
                                 treeStableTimer=5,
                                 builderNames=["bar-redhat-builder", "bar-x86-solaris-builder"])

上面各个Scheduler的branch属性会与change_path_split返回值元组中的第一个元素匹配,这样foo-ci-plan便是foo_proj的scheduler,而bar-ci-plan则是bar_proj的scheduler。这样某个项目路径下的文件变更只会触发对应的scheduler开始工作,不会出现误触发。

如果多个项目或一个项目的多个模块使用不同的源码库,同理,我们可以为c['change_source']赋予多个SVNPoller,例如:
c['change_source'] = [SVNPoller("svn://10.0.0.1:3000",
                                svnuser='tony',
                                svnpasswd='tony',
                                pollinterval=60,
                                split_file=change_path_split),

                      SVNPoller("svn://10.0.0.1:4000",
                                svnuser='tony',
                                svnpasswd='tony',
                                pollinterval=60,
                                split_file=another_change_path_split)]

与Scheduler的匹配方式也与上述描述一致,这里就不重复说明了。

不同项目的干系人多不相同,那么集成的结果是如何准确地反馈给项目各自对应的干系人呢?以Mail反馈通知为例,我在BuildBot手册中找到了两种方式,一种方式是通过设置Scheduler的owner属性,然后指定MailNotifier的sendToInterestedUsers=True,意图让BuildBot将Mail通知发到owner list中的每个邮件地址,但经测试后发现,这种方式似乎不好用,不知道是否是BuildBot对该功能的实现上存在问题。

另外一种方式则是配置多个MailNotifier。每个MailNotifier中指定对应builder的名称列表,并通过extraRecipients指定这些Builder对应的项目的干系人Mail地址列表,例如:

c['status'].append(mail.MailNotifier(fromaddr="foo-buildbot@buildbot.net",
                                     extraRecipients=["foo1@buildbot.net", "foo2@buildbot.net"],
                                     builders=['foo-x86-solaris-builder', 'foo-redhat-builder'],
                                     useTls=False,
                                     sendToInterestedUsers=False,
                                     relayhost="smtp.buildbot.net",
                                     smtpUser='tony',
                                     smtpPassword='tony',
                                     smtpPort=25))

c['status'].append(mail.MailNotifier(fromaddr="bar-buildbot@buildbot.net",
                                     extraRecipients=["bar1@buildbot.net", "bar2@buildbot.net"],
                                     builders=['bar-x86-solaris-builder', 'bar-redhat-builder'],
                                     useTls=False,
                                     sendToInterestedUsers=False,
                                     relayhost="smtp.buildbot.net",
                                     smtpUser='tony',
                                     smtpPassword='tony',
                                     smtpPort=25))

这样foo的builders构建的结果将发到foo1和foo2;而bar的builders构建结果将反馈到bar1和bar2。

多个项目共享一套BuildBot Master有利有弊,其不足之处可能有如下几点:
1、项目过多时,可能存在潜在的性能问题
2、Master的配置被多个项目共享,存在潜在的Conflict问题;
3、另外master.cfg可能size过大,也不利于阅读和维护。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 AI原生开发工作流实战 从 0 开始构建 Agent Harness Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com
这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats