速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

单元测试的五个主要准则

  • 2020-06-22
  • 本文字数:5908 字

    阅读完需:约 19 分钟

单元测试的五个主要准则


自动化测试是所有大型软件项目不可或缺的一部分。它是提高质量、生产力和灵活性的一种手段。 因此,对系统架构进行合理地设计以便利后续的开发和自动化测试变得至关重要。

自动化测试的好处

1.质量得以提高

因为自动化测试让我们能在开发阶段早日发现并解决问题,这避免了在变更部署到生产环境并提交给最终用户使用时发现问题。

2.生产力得到提高

因为在开发周期中发现问题的时间越早,修复该问题的成本越低,这不言而喻。如果软件开发人员能够在将代码集成到主代码仓库前运行自动化测试套件,那么可以快速发现新引入的 bug 并将其修复。但是,如果没有这样的测试套件,那么新引入的 bug 可能仅在最终用户使用测试阶段中出现,甚至出现更晚,这会导致开发人员暂停常规开发工作流程来对 bug 进行调查和修复,影响项目进度。

3.灵活性得到改善

有了测试套件的帮助,开发人员在进行代码重构、升级代码依赖包及修改系统特性时会更有信心,因为测试套件有非常高的测试覆盖率,可以方便地评估代码变更带来的影响。


在讨论自动化测试时,我也喜欢将风险管理的话题引入进来。作为首席软件工程师,风险管理是我工作的重要组成部分,它涉及指导开发团队进行工作和流程管理,减少产品技术退化的风险。 从上面列出的好处中可以明显看出,进行充分的自动化测试非常必要,这可以帮助减轻软件项目中的风险。

自动化测试类型

接下来,我们可以根据实现和运行自动化测试的策略将其分为至少三种不同类型,如下图著名的测试金字塔所示:



从时间和资源使用而言,单元测试的开发及运行成本低,并且单元测试专注于测试与外部依赖项隔离的单个系统组件(例如,业务逻辑)。


集成测试向前更进一步,并且在不隔离外部依赖关系的情况下进行开发和运行。在这种情况下,我们有兴趣评估所有系统组件构建在一起并面临集成约束(例如:联网、存储、处理等)时是否按预期进行交互。


最后,在金字塔的顶端,GUI 测试是整个自动化测试中代价最高的。他们通常依靠 UI 输入/输出脚本以及回放工具来模仿最终用户与系统图形用户界面的交互。


在本文中,我们将重点介绍测试金字塔的基础——单元测试,以及采用单元测试的系统体系结构在构建时的注意事项。

有效单元测试的属性

首先,让我们说明一下什么是有效的,设计良好的单元测试。


  • 简短——只有一个测试目的

  • 简单——设置及拆卸方便

  • 快速——可以快速执行

  • 标准——遵循严格的约定


理想情况下,单元测试应具有所有上述这些属性,下面将详细说明原因。


如果单元测试不够简短,将很难阅读并理解其目的,确切地说是很难理解测试内容。因此,出于这个原因,单元测试应该有一个明确目标,并且只评估测试一件事,而不是尝试同时执行多个测试目的。这样,当某个单元测试失败时,开发人员将更加轻松快捷地定位问题并进行修复。


如果单元测试需要大量精力来设置他们的测试环境,然后将其拆除,那么开发人员通常会开始质疑,花费在编写这些测试上的时间是否值得。因此,我们需要提供一个编写单元测试的环境,该环境要管理测试上下文的所有复杂性,例如依赖注入,数据预加载,缓存清除等。编写单元测试越容易,开发人员创建它们的动力就越大!


如果执行一组单元测试需要花费大量时间,则开发人员自然会减少执行频率。这里的问题在于拥有如此冗长的单元测试套件变得不切实际,开发人员会跳过运行单元测试或有选择地运行,从而降低了其有效性。


最后,如果测试没有一定的标准,不久之后你的测试套件开始看起来像未拓荒的美国西部一样,编写单元测试所使用的编码风格有时会有所不同,甚至会发生冲突。因此,在整个单元测试的范围内追求系统设计的连贯性在整个系统中都是有价值的。


一旦我们对有效的单元测试的架构达成共识,就可以开始定义提升其性能的系统架构准则,如以下各节所述。

一、软件复杂度

除其他因素以外,软件复杂度还源于系统内组件之间不断增加的交互及其内部状态的演变。随着复杂度的提高,无意识地干扰复杂的组件交互网络的风险也随之增加,这可能导致在代码变更时引入缺陷。


此外,通常情况下,系统的复杂性越高,维护和测试就越困难,这引出第一个(一般)准则:


密切关注软件的复杂度并遵循设计原则来控制它


在提高测试性能的同时管理复杂性的方面,值得一提的一个实践方法是,在系统设计中尽可能采用纯函数不变性。 纯函数是具有以下属性的函数:


  • 对于相同的参数,其返回值是相同的(不随局部静态变量,非局部变量,可变引用参数或来自 I/O 设备的输入的变化而变化)。

  • 它的评估测试不会产生副作用(局部静态变量,非局部变量,可变引用参数或 I/O 流不会因测试受到影响)。


从其属性可以明显看出,纯函数非常适合单元测试。它们的使用也消除了许多补充性实践的需求,这些补充性实践将在以下各节中讨论,以处理大部分为有状态的组件。


不变性起着同等重要的作用。不可变对象是创建后状态无法修改的对象。它们更易于交互和具有可预测性,从而有助于降低系统复杂性,消除全局状态。

二、依赖隔离

按照单元测试定义,单元测试旨在隔离测试各个系统组件,因为我们不希望组件的单元测试结果受到其依赖项的影响。隔离程度会根据被测组件的具体情况以及每个开发团队的偏好而有所不同。我个人不担心隔离轻量级的内部业务类,因为我发现,用功能几乎相同的测试组件替代它们不会显示有什么附加影响。这里的策略可能很简单:


在组件设计中应用依赖反转模式


依赖反转模式(DIP)指出,高级和低级对象都应依赖抽象(例如接口),而不是特定的具体实现。一旦将系统组件从其依赖关系中解耦出来,我们就可以在单元测试的上下文中通过简化的、针对测试的具体实现轻松地替换它们。下面的类图可以展示这种结构:



在此示例中,被测试的组件依赖 RepositoryFileStore 抽象类。当部署到生产环境中时,我们可能会为 Repository 类注入基于 SQL 的具体实现,并为文件存储组件注入基于 S3 的实现,以便在 AWS Cloud 中远程存储文件。不过,在运行单元测试时,我们将希望注入不依赖外部服务的简化功能实现,例如上图中绿色标记的“In Memory”实现。


如果你不熟悉 DIP,我曾发表一篇关于如何使用 DIP 的文章: Integrating third-party modules,这或许对你有所帮助。

三、Mocks vs Fakes

请注意,我没有将这些“in memory”实现称为“mocks”。mocks 指模拟对象,它以有限的受控方式模拟了真实对象的行为。我反对使用模拟对象,而赞成使用完全兼容的“fake”实现,是因为后者为我们提供了编写单元测试的更大灵活性,相比设置模拟对象,它以更加可靠的方式从多个单元测试类中进行重用。


为了更详细地说明,假设我们正在为依赖FileStore抽象类的组件编写单元测试。在此测试中,该组件将一条记录添加到文件存储中,但并不担心操作是否成功(例如,日志文件),因此我们决定以“虚拟”方式模拟该操作。


现在,假设稍后需求发生变化,并且组件需要确保在继续操作之前通过从文件存储中读取文件来创建文件,从而迫使我们更新模拟的行为以通过测试。然后,想象需求又发生了变化,并且组件需要写入多个文件(例如:每个日志级别对应一个日志文件),而不是只写入一个,从而迫使我们的模拟对象行为再次进行修改。你知道发生了什么吗?我们正在慢慢改进我们的模拟,使其代码更趋近于具体的实现。


更糟糕的是,我们最终可能会在整个代码库中散布数很多独立的,半成品的模拟实现,每个单元测试类对应一个,从而导致测试环境更多的维护工作以及较低的内聚性。


为了解决这种情况,我提出以下准则:


依靠 Fakes 而不是 Mocks 来实施单元测试,将其视为一等的公民,并将其组织为可重用的模块


由于 Fake 组件实现了业务行为,因此与设置模拟对象相比,它们本质上是更昂贵的初始投资。但是,它们的长期回报肯定更高,并且更符合有效的单元测试的特性。

四、编码风格

每个自动化测试都可以描述为三步:


  1. 准备测试环境

  2. 执行关键操作

  3. 验证结果


(Given)给定已知的初始状态,(When)然后执行某项操作,(Then)每次操作最终都应产生相同的预期结果,这是非常符合逻辑的思考过程。为了使结果变得不同,必须更改初始状态,或者更改操作实现本身。


你可能对上面用黑体字标出的单词很熟悉。它们代表了一种流行的 Given-When-Then 模式,利用该模式可以编写可读性高以及结构清晰的单元测试代码。这一概念很简单:


为单元测试定义和实施单一标准化的编码风格


Given-When-Then 模式有多种实现方式。其中一个方法是将单元测试方法构造为三种不同的方法。例如,考虑用户的密码强度测试:


[TestMethod]public void WeakPasswordStrengthTest(){    var password = GivenAWeakPassowrd();    var score = WhenThePasswordStrengthIsEvaluated(password);    ThenTheScoreShouldIndicateAWeakPassword(score);}private string GivenAWeakPassowrd(){    return "qwerty";}private int WhenThePasswordStrengthIsEvaluated(string password){    var calculator = new PasswordStrengthCalculator();    return (int)calculator.GetStrength(password);}private void ThenTheScoreShouldIndicateAWeakPassword(int score){    Assert.AreEqual((int)PasswordStrength.Weak, score);}
复制代码


使用这种方法时,主测试方法变成了对该单元测试的三行描述,即使是非开发人员也可以通过阅读来轻松理解。实际上,单元测试的主方法最终会成为系统行为的低级文档,不仅提供文本描述,还提供了执行代码、调试代码并定位内部问题的可能性。当新开发人员加入团队时,这对于缩短系统架构学习曲线非常有价值。


需要强调一下,在编码风格方面,没有唯一正确的方法。我在上面提供的示例可能会使某些开发人员感到不满,例如,因为代码冗长而令人不悦,不过这没关系。真正重要的是,应该在你的开发团队内部就编码规范约定达成一致,每一位成员应始终坚持按照该规范编写有意义的测试代码。

五、测试上下文管理

单元测试上下文管理是一个讨论不够多的话题。“测试上下文”是指成功运行单元测试所需的整个依赖注入以及初始状态设置。


如前所述,当开发人员花费更少的时间来设置测试上下文环境并腾出时间编写测试用例时,单元测试会更有效。我们从以下观察得出我们的最后一个准则,即大量的测试案例可以共享一些测试上下文:


利用构造器类将测试上下文的构建与单元测试用例的实现分开


这个想法是将测试上下文的构造逻辑封装在构造器类中,并在单元测试类中引用它们。然后,每个上下文构造器负责创建特定的测试方案,并可选择地定义用于使其特定化的方法。


让我们看一下另一个代码示例。假设我们正在开发一个反作弊组件,以检测移动应用程序用户可疑的位置变化。测试上下文构造器可能如下所示:


public class MobileUserContextBuilder : ContextBuilder{    public override void Build()    {        base.Build();        /*            The build method call above is used for            injecting dependencies and setting up generic            state common to all tests.            After it we would complete building the test            context with what's relevant for this scenario            such as emulating a mobile user account sign up.        */    }    public User GetUser()    {        /*            Auxiliary method for returning the user entity            created for this test context.        */    }    public void AddDevice(User user, DeviceDescriptior device)    {        /*            Auxiliary method for particularizing the test            context, in this case for linking another            mobile device to the test user's account            (deviceType, deviceOS, ipAddress, coordinates, etc)        */    }}
复制代码


MobileUserContextBuilder创建的测试上下文足够通用,从应用程序注册了移动用户的状态开始,任何测试用例都可以使用它。最重要的是,它定义了AddDevice方法,用于特定化测试上下文,以满足我们虚拟的反作弊组件测试需求。


该反作弊组件称为GeolocationScreener,它负责检查移动用户的位置是否改变得太快,如果变化太快,这表明用户可能是在伪造自己的真实坐标。其中的一个单元测试可能如下所示:


public class GeolocationScreenerTests{    [TestInitialize]    public void TestInitialize()    {        context = new MobileUserContextBuilder();        context.Build();    }    [TestMethod]    public void SuspiciousCountryChangeTest()    {        var user = GivenALocalUser();        var report = WhenTheUserCountryIsChangedAbruptly(user);        ThenAnAntiFraudAlertShouldBeRaised(report);    }    [TestCleanup]    public void TestCleanup()    {        context.Dispose();    }    private User GivenALocalUser()    {        return context.GetUser();    }    private SecurityReport WhenTheUserCountryIsChangedAbruptly(User user)    {        var device = user.CurrentDevice.Clone();        device.SetLocation(Location.GetCountry("Italy").GetCity("Rome"));        context.AddDevice(user, device);        var screener = new GeolocationScreener();        return screener.Evaluate(user);    }    private void ThenAnAntiFraudAlertShouldBeRaised(SecurityReport report)    {        Assert.AreEqual(RetportType.Geolocation, report.Type);        Assert.IsTrue(report.AlertRaised);    }    private MobileUserContextBuilder context;}
复制代码


可以看出,在此示例测试类中专用于设置测试上下文的代码量很小,因为它几乎完全包含在构造器类中,从而保留了代码的可读性和组织性。随着越来越多的测试用例利用方便的测试上下文构造器库,设置测试上下文所需的时间经过摊销后变得非常短。

总结

在这篇文章中,我讨论了单元测试的主题,提供了五个主要准则,以应对在不断增长的测试用例中保持有效性的挑战。这些准则对系统体系结构有重要影响,从软件项目开始就应该考虑单元测试要求并营造这种环境,让开发人员看到单元测试价值并激发开发人员编写单元测试。


单元测试应被视为系统体系结构的组成部分,与它们所测试的组件一样重要,而不应被视为二等公民,避免出现开发团队仅仅为了应付编写管理报告或提供指标而进行单元测试的现象。


最后,如果你在一个几乎没有单元测试的遗留项目中工作,且没有使用 DIP,那么本篇文章可能就没有适合你的最佳策略,因为我有意避开谈论那些复杂的模拟框架,而这些框架正是在遗留项目中将单元测试引入极端耦合代码的可行选择。


原文链接


On the architecture for unit testing


2020-06-22 11:334077
用户头像
王坤祥 日拱一卒,功不唐捐。

发布了 76 篇内容, 共 18.6 次阅读, 收获喜欢 127 次。

关注

评论

发布
暂无评论
发现更多内容

网站被ddos跟cc攻击会有什么影响,该如何去解决

德迅云安全_初启

测试开发 | 人工智能无监督学习(Unsupervised Learning)

测吧(北京)科技有限公司

测试

Mac电脑视频编辑处理:Apeaksoft Video Editor 激活最新版

mac大玩家j

Mac软件 视频处理工具 视频编辑器 视频编辑管理

测试开发 | 深入了解监督学习(Supervised Learning)

测吧(北京)科技有限公司

测试

Vue 2最终版本 v2.7.16 已发布

南城FE

JavaScript Vue 前端开发

Kosmos实战系列:有状态服务(MySQL)跨云灾备实战

畅聊云原生

基于「迭代分支」的 API 开发模式

Apifox

程序员 后端 Apifox 分支 API 开发

测试开发 | 拓展学习范式:人工智能半监督学习的探索与应用

测吧(北京)科技有限公司

测试

IPQ9574-IPQ8072 has 10G Ethernet port- What is the performance difference?

wifi6-yiyi

ipq9574 10G Ethernet port

数据服务化解耦 创新企业智能运营架构

用友BIP

数据服务

详解MRS HBase全局二级索引

华为云开发者联盟

大数据 后端 华为云 华为云开发者联盟

2024年度腾讯犀牛鸟精英人才计划开放申请

Geek_2d6073

好消息!华为云时习知荣获IXDC AWARD国际体验奖

华为云PaaS服务小智

云计算 华为云

文心一言 VS 讯飞星火 VS chatgpt (165)-- 算法导论13.1 5题

福大大架构师每日一题

福大大架构师每日一题

一起学Elasticsearch系列-写入和检索调优

Java随想录

Java 大数据 Elastic Search

GaussDB(DWS)中的分布式死锁问题实践

华为云开发者联盟

大数据 后端 华为云 华为云开发者联盟 华为云GaussDB(DWS)

第33期 | GPTSecurity周报

云起无垠

测试开发 | 深度学习:人工智能的前沿驱动力

测吧(北京)科技有限公司

测试

Kuasar成为CNCF官方项目,探索容器运行时新纪元

华为云开发者联盟

云原生 后端 华为云 华为云开发者联盟

《网络安全事件报告管理办法(征求意见稿)》正在公开征求意见

行云管家

网络安全 网络安全法

打破界限:一体化数据驱动低代码平台,构建业务生态新纪元

天津汇柏科技有限公司

低代码

测试开发 | 人工智能强化学习(Reinforcement Learning)

测吧(北京)科技有限公司

测试

【坚果派】JS开源库适配OpenHarmony系列——第一期实操

白晓明

OpenHarmony JS开源库 ArkTS开源库

软件测试/测试开发丨软件测试的基本概念

测试人

软件测试

好用的IOS数据传输工具:FonePaw iOS Transfer中文激活

胖墩儿不胖y

Mac软件 iOS数据传输工具

Flink Has Become the De-facto Standard of Streaming Compute

Apache Flink

大数据 flink 实时计算

移动端防截屏录屏技术在百度账户系统实践

百度Geek说

移动端 企业号12月PK榜 防截屏录屏 百度账户系统

带你了解决策树模型

小齐写代码

单元测试的五个主要准则_语言 & 开发_thomas vilhena_InfoQ精选文章