单元测试从入门到精通

导航

1前言

2什么是单元测试

3为什么要进行单元测试

3.1降低代码缺陷

3.2推动架构优化

3.3守护代码迭代质量

4如何进行单元测试

4.1使用AAA规则编写测试用例

4.2让每个测试用例符合AIR特性

4.3在需要的时候使用测试替身

4.4了解单元测试覆盖方式

4.5单元测试过程中的一些常见疑问

5可测试性设计

5.1分层设计

5.2抽象设计

5.3依赖注入

6可测试性编码

6.1注入协作对象

6.2不要依赖静态方法

6.3不要依赖全局变量

6.4不要为了测试而测试

7使用测试框架

7.1GTest简介

7.2使用GTest编写单元测试用例

7.3单元测试覆盖率统计

8单元测试的成败关键

8.1时间与成本预算

8.2在软件架构设计阶段整体考虑可测试性(架构师)

8.3在编码阶段具备可测试性意识(开发工程师)

9后记

1 前言

这篇文章源于工作中的一个项目,2021年,我负责汇川技术工业机器人应用软件的基础架构重构,当时单元测试是重构工作的核心环节之一,从无法进行单元测试到最终60%以上的行覆盖率,过程中自己也有非常多的收获,于是将其整理成文,希望对计划开展和正在开展单元测试的同学有所帮助。

2 什么是单元测试

软件测试过程V模型

单元测试(Unit Testing),是指对软件中的逻辑单元或组件进行检查和验证,以确保其按预期执行。通常单元测试是软件开发过程中进行的最低级别测试活动,通过单元测试可发现和修复软件开发早期的BUG和缺陷。

单元(Unit),是一个应用程序中最小的可测试部分,在面向过程开发中,单元通常为函数(Function),在面向对象开发中,单元通常为类中的方法(Method)。

3 为什么要进行单元测试

3.1 降低代码缺陷

测试左移-单元测试

单元测试的首要目标是降低代码缺陷,如上图所示,当代码缺陷越早被发现,它的修复成本就越低,这种把测试尽量提前进行的思想就叫做测试左移。

3.2 推动架构优化

单元测试与软件架构

单元测试与软件架构有着非常紧密的联系,通常越是架构设计优秀的项目(比如符合SOLID规则),越容易实施单元测试,反之越是架构糟糕的项目,越难以实施单元测试。并且单元测试以及其严格的方式要求软件架构设计,如果架构设计存在问题,单元测试就根本无法开展。

假如你发现在自己的项目中实施单元测试举步为艰,那么首先应该停下来观察和思考一下,项目架构设计的是否合理?比如一些项目中UI与业务逻辑耦合,单元测试无法命中核心业务逻辑,可思考一下项目是否需要分层设计?UI与业务逻辑是否需要分离?比如项目中当碰到物理设备的依赖,单元测试就被阻断,可思考一下是否永远只使用这一台设备?后续有没有可能换成其它设备?是否需要考虑扩展性?再比如发现被测对象就是铁板一块,根本不能改变其协作对象的行为和数据,那么就应思考一下,对象之间是否存在强耦合?是否可以通过依赖注入降低和消除对象之间的耦合?

大多数情况我们在项目中实施单元测试的目的是为了保障代码质量,但我认为单元测试对软件架构优化的驱动实际更为重要。

3.3 守护代码迭代质量

单元测试守护代码迭代质量

在当下的商业环境中,大鱼吃小鱼,快鱼吃慢鱼,对软件开发的效率要求越来越高,传统的瀑布开发模式越来越少,敏捷开发模式越来越普及。因此软件版本快速迭代,快速测试,快速发布,小步快跑在大多数项目中成为常态。

而单元测试在代码快速迭代过程中发挥着守护代码质量的至关重要作用。当单元测试覆盖了一个模块中的业务逻辑,该业务逻辑在迭代变更过程中出现任何问题,会第一时间自动被单元测试捕获,因此单元测试对发生变更的代码正确性提供了保障,同时开发人员在这样的保障下可以大胆的对代码进行重构,对业务逻辑进行增减变更调整。大名鼎鼎的TDD(测试驱动开发)就是基于这个原理。

4 如何进行单元测试

4.1 使用AAA规则编写测试用例

我们用一个简单的示例来演示单元测试的编码过程,如下代码所示,是一个非常简单的方法,它根据不同的距离,推荐不同的交通工具:

cpp 复制代码
class UnitTestDemo
{
    public:
    // 一个被测方法,根据距离推荐交通工具
    // distance参数:距离(单位为千米)
    string StransportForDistance(float distance)
    {
        // 100公里内推荐的士  
        if (distance <= 100)  
            return "的士";  

        // 1000公里内推荐高铁  
        if (distance <= 1000)  
            return "高铁";  

        // 大于1000公里推荐飞机  
        return "飞机";  
    }  
}  

然后我们为这个方法编写单元测试用例,它同样非常的简单:设定条件,调用被测方法,断言返回结果:

cpp 复制代码
// 一个单元测试用例
TEST(UnitTestDemo, TransportForDistance_Texi)
{
    // 设置初始条件(Arrange)
    UnitTestDemo unitTestDemo;
    float distance = 30;

    // 执行业务逻辑(Act)
    string s = unitTestDemo.TransportForDistance(distance);

    // 断言测试结果(Assert)
    EXPECT_EQ(s, "的士");
} 

至此,单元测试的编码就完成了。是的,单元测试的编码已全部完成了,花30秒看懂这个示例,你就掌握了单元测试的核心方法。为了方便记忆,有人将它总结成了AAA规则:

Arrange

设置条件

Act

执行逻辑

Assert

断言结果

4.2 让每个测试用例符合AIR特性

AAA规则告诉我们如何编写单元测试用例,但要编写一个合格的单元测试用例,就需要了解单元测试用例的基本特性,这些特性就像空气(AIR)一样重要,任何时候我们也不能离开:

Automatic

自动化:单元测试应自动执行,而无需任何交互,测试用例通常被定期执行。

Independent

独立性:每个单元测试用例都是独立的个体,不允许测试用例之间存在依赖关系,也不允许要求测试用例被执行的先后顺序。

Repeatable

可重复:单元测试用例在被重复执行时应稳定的返回相同的结果,不能受外部环境的影响。

4.3 在需要的时候使用测试替身

什么是测试替身

比如业务中我们的代码与硬件设备连接,需要依赖硬件的不同状态来执行不同的逻辑。单元测试的特性是随时可重复执行,对硬件的依赖会阻塞单元测试执行,因此在单元测试用例中需要用一个"替身"替换掉硬件的状态,这个"替身"就叫做测试替身。

测试替身的应用场景

1、真实对象具有不可确定的行为,或产生不可预测的结果。

2、真实对象很难被创建或创建成本过大,比如第三方系统、与硬件设备关联的模块。

3、真实对象的某些行为很难触发,比如异常的触发。

4、真实对象令测试用例的执行速度很慢。

5、真实对象有含有人机交互界面。

4.4 了解单元测试覆盖方式

语句覆盖

语句覆盖又称为行覆盖,是单元测试中最简单也是最常见的覆盖率统计方式。被测函数中,只要被单元测试用例执行到的行,即认为该行被覆盖到。比如一个100行的函数,其中有60行被单元测试用例执行到,那么语句覆盖率为60%。

分支覆盖

分支覆盖又称为判定覆盖,它关注的是被测函数中产生分支的if判定结果,只要每个if语句判定为真和判定为假的分支都被执行到,即达成了分支覆盖。注意分支覆盖并不考虑多个分支间的组合关系。

条件覆盖

条件覆盖关注的是判定语句中的每个表达式是否被执行。比如判定语句 if (a() || b()) ,当a()返回为真时就不再执行b()了,此时就未达成条件覆盖;要达成条件覆盖,就需要使a()返回假。

路径覆盖

路径覆盖是单元测试中覆盖最全的一种方式,它要求覆盖被测试方法中所有逻辑分支路径的组合。

总结

语句覆盖在单元测试覆盖率统计中最为常见,基本是一个必选项,分支覆盖与条件覆盖可作为进阶选择,路径覆盖最为完善,但是在复杂的业务场景中,会导致单元测试代码指数级增长。建议根据实际情况灵活组合搭配。

4.5 单元测试过程中的一些常见疑问

由谁来编写单元测试用例?

应该由开发人员编写自己开发的功能对应的单元测试用例。有些项目中会安排专人来为其它人开发的功能编写单元测试用例,这样做效率很低,因为单元测试用例的编写人员需要花费时间了解和学习代码逻辑。

什么时间节点写单元测试用例?

通常应该在功能开发完成后即编写与之对应的单元测试用例,即使有延迟也不要延迟太长时间,时间过长会导致编写单元测试用例时需要重新回顾代码逻辑所带来的额外时间成本。

单元测试是白盒还是黑盒测试?

绝大部分的单元测试是白盒测试,会根据函数中的逻辑设计编写测试用例,以达到覆盖率目标。但单元测试也可以是黑盒测试,比如一些API接口只关注输入与输出而不关注内部的逻辑实现。

5 可测试性设计

5.1 分层设计

可测试性设计-分层设计

将一件复杂的事情进行分解,是提升效率的基本手段,这在日常生活中非常常见。比如汽车的生产过程离散在多个零部件生产线,最后完成组装。软件中的分层设计,也是最常见的一种架构模式,在流行的开发框架中随处可见。分层设计可以帮助单元测试准确的命中目标,比如通常情况下我们并不需要对UI而只希望对核心的业务逻辑进行单元测试,如果没有分层,UI与业务逻辑耦合,就会使单元测试无法准确命中目标甚至寸步难行。

5.2 抽象设计

可测试性设计-抽象设计

抽象是增强软件扩展性的一把利剑,主板厂商很早就把抽象应用自如了。比如主板上的USB接口,并不针对某一种具体的设备,而只定义了USB标准:接口尺寸、电流、电压、数据传输协议等,然后依据这个标准生产主板。USB标准即抽象,主板厂商通过抽象获得了对无限种类USB设备的扩展支持。

USB接口抽象示例

因为有了USB标准,所以很容易就可以设计生产一个USB测试工装,这个工装就类似于单元测试中的测试替身,主板厂商在测试时,并不需要外接一个用户经常使用的U盘或USB键盘,而只需要外接一个USB测试工装即可完成测试,并且这个测试工装可以在符合USB基本标准的前提下按测试需求设计生产,比如只需要按数据传输标准接收数据即可而并不需要真正的存储数据。

5.3 依赖注入

可测试性设计-依赖注入

当发生火警时,消防通道的畅通保障了救援。在单元测试中,依赖注入保障了代码的可测试。由此可见,依赖注入在可测试性编码中的重要性。

csharp 复制代码
// 上帝视角
上帝造人()
{
    自己捏脑袋();
    自己捏胳膊();
    自己捏腿();
    ......
}

// 依赖注入
上帝造人( 脑袋, 胳膊, 腿 ......)
{
    组装脑袋();
    组装胳膊();
    组装腿();
    ......
} 

如果所有职业按成就感进行排名的话,我想软件开发一定是名列前茅的,因为大多数时候软件开发人员扮演的就是"上帝角色",他们可以随时new一切需要的对象。但在依赖注入模式下,上帝需要从"自己创造"转变为"习惯组装"。

6 可测试性编码

Google的研发工程师写了一篇关于软件可测试性的文章《Guide: Writing Testable Code》,觉得里面的代码示例比较具有代表性,摘录并整理简化了代码(可不关注语法细节,当作伪代码来看)如下:

6.1 注入协作对象

难以测试的代码示例:

csharp 复制代码
// 被测对象
public class House
{
    private Bedroom bedroom;
    House() 
    { 
        // 在类的构造函数中构造协作对象,可测试性差。
        bedroom = new Bedroom(); 
    }
    // ...
}

// 测试用例
public void TestThisIsReallyHard()
{
    House house = new House();
    // 无法控制Bedroom对象,难以测试
    // ...
} 

易于测试的代码示例:

csharp 复制代码
// 被测对象
public class House
{
    private Bedroom bedroom;
    // 注入协作对象,可测试性好。
    House(Bedroom b) 
    { 
        bedroom = b;
    }
    // ...
}

// 测试用例
public void TestThisIsEasyAndFlexible()
{
    // Bedroom对象在掌控之中,易于测试
    Bedroom bedroom = new Bedroom();
    House house = new House(bedroom);
    // ...
} 

6.2 不要依赖静态方法

难以测试的代码示例:

csharp 复制代码
// 被测对象
public class TrainSchedules 
{ 
    Schedule FindNextTrain() 
    { 
        // 与静态方法强耦合,难以测试
        if (TrackStatusChecker.IsClosed(track)) 
        { 
            // ...
        } 
        // ... return a Schedule
    } 
}

// 测试用例
public void TestFindNextTrainNoClosings()
{
    // 静态方法出现长耗时,阻塞单元测试
    AssertNotNull(schedules.FindNextTrain());
}

易于测试的代码示例:

csharp 复制代码
// 将静态方法包装在一个注入类中,并将其抽象(设计实现IStatusChecker接口)
public class TrackStatusCheckerWrapper : IStatusChecker
{ 
    public bool IsClosed(Track track) 
    { 
        return TrackStatusChecker.IsClosed(track); 
    } 
} 

// 被测对象
public class TrainSchedules 
{ 
    private StatusChecker wrappedLibrary; 
    // 1、通过依赖注入解除与静态方法之间的强耦合
    // 2、支持通过抽象使用测试替身,消除长耗时
    public TrainSchedules(IStatusChecker wrappedLibrary) 
    { 
        this.wrappedLibrary = wrappedLibrary; 
    } 

    public Schedule FindNextTrain() 
    { 
        if (wrappedLibrary.IsClosed(track)) 
        { 
            // ... 
        } 
        // ... return a Schedule 
    } 
}

// 测试用例
public void TestFindNextTrainNoClosings() 
{
    // 支持通过抽象替换掉静态方法中的耗时逻辑
    IStatusChecker localWrapper = new StubStatusCheckerWrapper();
    TrainSchedules schedules = new TrainSchedules(localWrapper);
    AssertNotNull(schedules.FindNextTrain()); 
} 

6.3 不要依赖全局变量

难以测试的代码示例:

csharp 复制代码
// 被测对象
public class NetworkLoadCalculator
{
    public int CalculateTotalLoad()
    {
        // 依赖全局变量,难以测试
        string algorithm = ConfigFlags.FLAG_loadAlgorithm.Get();
        // ...
    }
}

// 测试用例
public void TestMaximumAlgorithmReturnsHighestLoad() 
{ 
    // 缺陷1:一旦忘了复原全局变量就会导致后续其它测试用例执行失败
    // 缺陷2:全局变量导致测试用例无法并行执行
    // 设置全局变量
    ConfigFlags.FLAG_loadAlgorithm.SetForTest("maximum"); 
    NetworkLoadCalculator calc = new NetworkLoadCalculator(); 
    calc.SetLoadSources(10, 5, 0); 
    AssertEquals(10, calc.CalculateTotalLoad()); 
    // 复原全局变量
    ConfigFlags.FLAG_loadAlgorithm.ResetForTest(); 
} 

易于测试的代码示例:

csharp 复制代码
// 被测对象
public class NetworkLoadCalculator 
{ 
    private string loadAlgorithm; 
    // 使用依赖注入,可测试性好
    NetworkLoadCalculator(string loadAlgorithm) 
    { 
        this.loadAlgorithm = loadAlgorithm; 
    } 
    // ... 
}

// 测试用例
public void TestMaximumAlgorithmReturnsHighestLoad() 
{
    // 不再依赖全局变量,解除了测试用例之间相互影响的风险
    NetworkLoadCalculator calc = new NetworkLoadCalculator("maximum"); 
    calc.SetLoadSources(10, 5, 0); 
    AssertEquals(10, calc.CalculateTotalLoad()); 
} 

6.4 不要为了测试而测试

难以测试的代码示例:

csharp 复制代码
// 协作对象
public class VideoPlaylistIndex
{
    private VideoRepository repo;
    // 单元测试专属构造函数,业务中不使用
    public VisibleForTesting VideoPlaylistIndex (VideoRepository repo)
    {
        this.repo = repo;
    }
    // 业务中使用的构造函数
    public VideoPlaylistIndex()
    {
        // 执行缓慢的逻辑
        this.repo = new FullLibraryIndex();
    }
}

// 被测对象
public class PlaylistGenerator
{
    private VideoPlaylistIndex index = new VideoPlaylistIndex();
    public Playlist buildPlaylist(Query q)
    {
        return index.search(q);
    }
}

// 测试用例
public void TestBadDesignHasNoSeams()
{
    // 虽然VideoPlaylistIndex容易进行测试,但PlaylistGenerator却难以测试,执行缓慢的构造函数无法被替换
    PlaylistGenerator generator = new PlaylistGenerator();
} 

易于测试的代码示例:

csharp 复制代码
// 协作对象
public class VideoPlaylistIndex
{
    private VideoRepository repo;
    // 业务与单元测试共用构造函数
    public VisibleForTesting VideoPlaylistIndex (VideoRepository repo)
    {
        this.repo = repo;
    }
}

// 被测对象
public class PlaylistGenerator
{
    private VideoPlaylistIndex index;
    // 使用依赖注入
    public PlaylistGenerator(VideoPlaylistIndex index)
    {
        this.index = index;
    }
    public Playlist buildPlaylist(Query q)
    {
        return index.search(q);
    }
}

// 测试用例
public void TestFlexibleDesignWithDI()
{
    // 通过依赖注入替换掉可能耗时的操作
    VideoPlaylistIndex fakeIndex = new InMemoryVideoPlaylistIndex();
    PlaylistGenerator generator = new PlaylistGenerator(fakeIndex);
} 

7 使用测试框架

7.1 GTest简介

测试框架为我们提供了测试用例管理、断言、参数化、用例执行等系列通用功能,使我们可以专注于测试用例本身业务逻辑的处理。在C/C++编程中,GTest当前最流行的单元测试框架,它由Google公司发布,支持跨平台(Linux、Windows、MacOS),GTest官方仓库地址为:https://github.com/google/googletest

7.2 使用GTest编写单元测试用例

cpp 复制代码
// 一个简单的单元测试用例示例
TEST(Test_Suite_Name, Test_Case_Name)
{
    // 设置初始条件
    // ...

    // 调用被测试方法
    // ..

    // 断言测试结果
    EXPECT_EQ(varString, "Assert Result");
} 

GTest框架会自动执行所有单元测试用例(由TEST、TEST_F等宏定义),一个单元测试用例类似于一个函数,其中第一个参数为测试套件名称,测试套件就是一系列单元测试用例的集合,第二个参数为单元测试用例名称,如上代码所示。

cpp 复制代码
// 自定义一个测试套件
class Test_Suite : public ::testing::Test
{
protected:
    // 全局初始化(所有测试用例执行前)
    static void SetUpTestCase()
    {}
    // 全局清理(所有测试用例执行后)
    static void TearDownTestCase()
    {}
    // 测试用例初始化(单个测试用例执行前)
    void SetUp() override
    {}
    // 测试用例清理(单个测试用例执行后)
    void TearDown() override
    {}
    // 其它公共资源
    // ...
}

// 使用自定义测试套件的单元测试用例
TEST_F(Test_Suite, Test_Case_Name)
{
    // 设置初始条件
    // ...

    // 调用被测方法
    // ..

    // 断言测试结果
    EXPECT_TRUE(varBool);
} 

在实际项目中,通常相同类型的多个测试用例需要相同的初始化和清理过程,或需要共用一些资源。此时就可以使用自定义测试套件方式,如上代码所示。

7.3 单元测试覆盖率统计

单元测试覆盖率通常指的是行覆盖率,其计算规则为:分母为被测项目有效代码(排除空白、注释等无效行)的总行数,分子为被单元测试用例执行到的行数,由此计算的比例为单元测试行覆盖率。华为大多软件项目对外宣称的单元测试行覆盖率为70%,根据我的经验,这是一个相当高的比例了。

有很多统计单元测试覆盖率的工具,比如针对C++的 OpenCppCoverage ,安装后通过一条命令即可生成HTML可视化的单元测试覆盖率统计报表:

powershell 复制代码
OpenCppCoverage.exe --source 待分析的源代码目录 -- 单元测试项目生成的.exe

8 单元测试的成败关键

8.1 时间与成本预算

决定在项目中实施单元测试前,需要与项目经理充分沟通项目时间周期与成本,因为单元测试需要增加开发工程师在编码阶段的时间投入,这个比例大致在0.5~1.0之间。即假如某个功能的编码时间是10天,那么需要增加大约5-10天来完成单元测试。同时单元测试并非一劳永逸,后续当被测试的业务代码发生变更,与之对应的单元测试用例也需要同步变更。因此获得相应的项目资源预算对单元测试的成败至关重要,如果没有给到开发人员相对充裕的时间,但又要求他们达成单元测试指标,就会导致开发人员认为单元测试挤占了功能开发时间,从而排斥单元测试。

8.2 在软件架构设计阶段整体考虑可测试性(架构师)

软件可测试性设计

可测试性架构设计是达成单元测试在技术层面最重要的环节,好比房屋装修,如果软装都完成了,冰箱彩电空调摆放就位,才发现忘了走电源线,那么补救成本就非常高了。

8.3 在编码阶段具备可测试性意识(开发工程师)

除了架构设计提前考虑对单元测试的支持,软件编码亦是如此,开发人员在编写代码前应提前了解单元测试,以不至于编写出来的代码不能或难以进行单元测试。比如全局变量满天飞导致测试用例之间相互影响,类中的协作对象完全不使用依赖注入导致测试用例无从下手,等等。

9 后记

本文介绍了单元测试的基本概念,以及结合实际项目,分享了单元测试实施要点。是对自己项目过程的总结,也希望对有需要的同学有所帮助。

最后做一点补充,实施单元测试大致分为两类,我称之为主动单元测试和被动单元测试,主动单元测试,是以提升代码质量和软件架构为目的,由内部主动发起,实施过程中会同步优化软件架构、提升代码可测试性。而被动单元测试由外部驱使,比如来自客户或市场的外部要求,它以覆盖率为唯一目标,通常会借助一些商业工具(比如Tessy),自动生成单元测试用例与完成打桩,它不需要修改源程序代码,当然也不会提升软件的架构质量。本文所描述的,以及我个人比较推崇的为主动单元测试。

<全文完>