软件匠艺之良好单元测试的必要性

单元测试

什么是单元测试

你可以在互联网上找到众多的定义,以下我引用维基百科的定义和《单元测试艺术》的定义。

单元测试是一段测试代码,它调用软件代码的某个工作单元,并检查该工作单元的一个特定功能和最终执行结果。 如果期望的结果和最终执行的结果不一致,则单元测试就会失败。 一个单元测试的范围可以小到一个方法,也可以大到多个类。 –《单元测试艺术》

单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。 — 维基百科

如果在没有单元测试的情况下,程序员通常在写完代码时,需要手动运行程序或者通过调试工具来检验代码功能是否满足期望,引入单元测试后,就是将这部分工作完全自动化,使用代码来测试代码。因此程序员是编写单元测试的第一责任人,因为程序员在编写软件代码时,需要保证代码交付的质量。执行单元测试,就是为了证明这段代码的行为和我们期望的一致。单元测试是对软件设计的最小单位进行正确性检查的测试工作,其测试目的在于发现模块内部存在的各种错误。单元测试的要点是进行单元模块所有数据项的正确性、完善性测试,主要关注模块的算法细节和模块接口间流动的数据。

为什么需要单元测试

想想看我在A公司经历的情况,整个软件系统没有任何自动化测试用例支撑,只是通过部分手动测试验证功能的可用性是远远不够的,这样的软件系统其实是维护人员的噩梦,因为深藏在代码里的bug就像一颗颗定时炸弹,随时都有可能让你的系统崩溃。
通过使用单元测试,软件质量可以达到一下目标:

  • 软件得到充分的测试

    通过单元测试的定义可以看出,单元测试的对象其实是函数或者类,属于软件系统的最小功能单元。由于只关注软件的最小部件,因此测试目标和测试用例比较容易建立,不容易遗漏,测试覆盖会更加充分。

  • 软件的异常分支得到良好的覆盖

    手动测试相比单元测试最大的弱点是无法充分的覆盖异常情况,手动测试更多的只能验证软件的功能,有些异常很难模拟。但是单元测试可以通过使用测试框架,对软件单元外界的异常进行模拟,可以对异常分支进行充分的验证。

  • 测试的自动化保证了软件系统能够得到持续系统验证

    单元测试用例的集合和代码一样,都是软件生命周期中的重要组成部分,当软件系统在不断演进的过程中,单元测试用例的集合可以保证系统功能按照期望的方式运行,新增的代码对原有系统功能没有影响。单元测试用例的集合可以帮助程序员快速的发现新功能对原有功能的冲击和影响。

  • 单元测试帮助程序员尽快的发现问题,而不是隐藏问题

    传统的开发模式下,程序员只关注软件需求和功能的实现,因此软件代码都是一气呵成,聪明的程序员写代码的热情一旦被激发,就会一发不可收拾,当软件主要功能的代码实现后,他们才会去考虑功能及可靠性的测试。在这种开发模式下,一个很大的缺陷是程序员的手动测试用例很难充分覆盖,因此时常会出现漏测,和考虑不周的测试,很多隐藏的异常问题很难被发现。同时,当程序员手动测试发现问题时,调查和解决问题的效率很低,由于新增和修改的代码太多,并且很多代码已经是几天或者数周前写的,此时程序员早已对旧代码的逻辑变得陌生,很难通过阅读代码快速发现问题,必须借助一些调试工具(GDB 等)进行调试。我就有类似的经历,职业之初时常因为一个简单的逻辑和拼写错误耗费数小时更甚于数天的时间来定位。
    TODO: picture for 传统的开发调试模式
    如果程序员采用TDD(即测试驱动开发)开发模式的话,单元测试用例是在开发业务代码之前编写,因此每当你完成部分业务代码,通过运行测试用例,程序员可以快速得到反馈,由于开发测试用例和业务代码几乎是同时进行的,因此当用例失败时,程序员需要定位的代码片段非常小,并且这些代码几乎是在十几分钟之前开发的,程序员清楚的知道自己修改了什么,很多时候只需要通过快速的阅读代码就可以解决问题。
    单元测试的另外一个好处是,当程序员对已有软件系统进行修改时,通过运行单元测试可以得到快速反馈,如果受到影响,则可以快速发现问题,不用推迟到集成测试阶段。并且单元测试发现的问题要比集成测试发现的问题更容易调查和调试。

写出良好的单元测试的必要性

通过我在B公司X团队经历证明,通过覆盖充分的测试用例并不一定能在软件生命周期中带来正向反馈,相反,那些随着时间推移逐渐腐臭的测试用例代码,反而成了程序员的噩梦,这样的用例无法在保证产品质量上带来任何好处。实践证明,通过覆盖率单纯的来衡量单元测试质量会适得其反,聪明的程序员们常常会投机取巧,为了满足覆盖率而做一些对质量毫无意义的事情,恰恰是这样的作法,使得单元测试的质量大打折扣,随着时间的推移变成了累赘。

单元测试诚然是保证代码质量的一项关键技术,但是程序员首先要认识到,单元测试代码的质量和产品代码的质量同样重要。其次需要掌握生产良好单元测试代码的相关技术。

我可以列举一些糟糕单元测试的例子,以及他们的副作用:

  • 糟糕的可读性
  • 单个测试用例测试多个场景
  • 用例执行慢
  • 测试用例依赖于外部环境
  • 过多的重复代码

良好的单元测试具有以下一些属性:

  • 能够完全自动化;
  • 完全控制所有正在运行的部分(在需要时使用模拟或存根来实现这种隔离);
  • 如果是许多其他测试的一部分,可以按任何顺序运行;
  • 在内存中运行(例如,无 DB 或文件访问);
  • 始终返回相同的结果(例如,您始终运行相同的测试,因此没有随机数。保存那些用于集成或范围测试);
  • 运行快(单个用例执行只需要几毫秒);
  • 测试系统中的单个逻辑概念;
  • 良好的可读性;
  • 良好的可维护性;
  • 值得信赖(当你看到它的结果时,你不需要为了确定而调试代码);

如何在运行时使用 CppUTest 注入模拟类

这是一个简单的指南,教您如何将模拟类注入产品代码中,并让您的产品类在 CppUTest 单元框架下可测试。

下图是类之间的关系。ProductClassB 依赖于 ProductClassA(ProductClassB 调用 ProductClassB.method() 中的 ProductClassA.method1()),现在当我们对 ProductClassB 进行单元测试时,应该模拟 ProductClassA。

下面的代码展示了如何使用 CppUTest 使用新方法来模拟依赖的类。

ProductClassA 有一些方法,如果您希望这些方法可以被模拟,则应将这些方法声明为虚拟方法。

ProductClassA.hpp:

#ifndef _INCLUDE_PRODUCT_A_CLASS_
#define _INCLUDE_PRODUCT_A_CLASS_
#include <string>

class ProductClassA {
public:
ProductClassA() {
}

~ProductClassA() {
}
public:
virtual int method1(const std::string& arg1);
virtual int method2(const std::string& arg1);
};

#endif

ProductClassA.cpp:

#include <iostream>
#include "ProductClassA.hpp"

int ProductClassA::method1(const std::string& arg1) {
std::cout << arg1 << " ProductClassA class method1" <<std::endl;
return 0;
}

int ProductClassA::method2(const std::string& arg1) {
std::cout << arg1 << " ProductClassA class method2" <<std::endl;
return 0;
}

如果你想在对 ProductClassB 进行单元测试时模拟依赖的类 ProductClassA,则应在 ProductClassB 中声明一个具有引用类型的私有 ProductClassA 变量,并且在 ProductClassB 中可以有两个构造函数,一个用于产品代码,一个用于单元测试代码,如下所示。

ProductClassB.hpp:

#include <string>
#include "ProductClassA.hpp"

class ProductClassB {
public:
// for unit test
ProductClassB(ProductClassA& ainstance): _ainstance(ainstance) {
}
// for product code
ProductClassB(): _ainstance(ProductClassA()) {
}
~ProductClassB() {
}

public:
int method();

private:
ProductClassA& _ainstance;
};

ProductClassB.cpp:

#include "ProductClassB.hpp"
#include <iostream>

int ProductClassB::method() {
std::cout<< "this is B class method"<<std::endl;
return this->_ainstance.method1("B invoke");
}

ProductClassAMock类是ProductClassA的派生类,其中的方法将以mock的方式重新实现。

ProductClassAMock.hpp:

#include "ProductClassA.hpp"

class ProductClassAMock : public ProductClassA
{
public:
virtual int method1(const std::string& arg1);
virtual int method2(const std::string& arg1);
};

ProductClassAMock.cpp:

#include "ProductClassAMock.hpp"

#include <iostream>
#include "CppUTest/TestHarness.h"
#include "CppUTestExt/MockSupport.h"

int ProductClassAMock::method1(const std::string& arg1) {
std::cout << arg1 << " ProductClassAMock method1"<<std::endl;
mock().actualCall("method1");
return 0;
}

int ProductClassAMock::method2(const std::string& arg1) {
mock().actualCall("method2");
return 0;
}

ProductClassB 的单元测试将使用测试构造函数在 bInstance 中用模拟类 ProductClassAMock 初始化 _ainstance,然后根据 C++ 多态机制,将 _ainstance.method1() 替换为类 ProductClassAMock 中的 method1。

ProductClassB_unittests.cpp:

#include "ProductClassB.hpp"
#include "ProductClassAMock.hpp"

#include "CppUTest/CommandLineTestRunner.h"
#include "CppUTest/TestHarness.h"
#include "CppUTestExt/MockSupport.h"

TEST_GROUP(BClassFooTests)
{
void teardown()
{
mock().clear();
}
};

TEST(BClassFooTests, MockAsExpected)
{
mock().expectOneCall("method1");
ProductClassAMock aMockInstance;
ProductClassB bInstance(aMockInstance);
bInstance.method();
mock().checkExpectations();
}

int main(int ac, char** av)
{
return CommandLineTestRunner::RunAllTests(ac, av);
}

产品代码只需使用ProductClassB的产品构造函数,bInstance._ainstance将使用真实的类ProductClassA进行初始化。

main.cpp:

#include "ProductClassB.hpp"

int main() {
ProductClassB bInstance();
bInstance.method();
return 0;
}

如何构建代码:

# build main
g++ ProductClassA.cpp ProductClassB.cpp main.cpp -o main

# build unit test
g++ ProductClassA.cpp ProductClassB.cpp ProductClassAMock.cpp ProductClassB_unittests.cpp -lstdc++ -lCppUTest -lCppUTestExt -o testrunner

参考:

本文系本站原创文章,著作版权属于作者,未经允许不得转载,如需转载或引用请注明出处或者联系作者。