自动化测试是所有大型软件项目不可或缺的一部分。它是提高质量、生产力和灵活性的一种手段。因此,对系统架构进行合理地设计以便利后续的开发和自动化测试变得至关重要。
自动化测试的好处
1、质量得以提高
因为自动化测试让我们能在开发阶段早日发现并解决问题,这避免了在变更部署到生产环境并提交给最终用户使用时发现问题。
2、生产力得到提高
因为在开发周期中发现问题的时间越早,修复该问题的成本越低,这不言而喻。如果软件开发人员能够在将代码集成到主代码仓库前运行自动化测试套件,那么可以快速发现新引入的 bug 并将其修复。但是,如果没有这样的测试套件,那么新引入的 bug 可能仅在最终用户使用测试阶段中出现,甚至出现更晚,这会导致开发人员暂停常规开发工作流程来对 bug 进行调查和修复,影响项目进度。
3、灵活性得到改善
有了测试套件的帮助,开发人员在进行代码重构、升级代码依赖包及修改系统特性时会更有信心,因为测试套件有非常高的测试覆盖率,可以方便地评估代码变更带来的影响。
在讨论自动化测试时,我也喜欢将风险管理的话题引入进来。作为首席软件工程师,风险管理是我工作的重要组成部分,它涉及指导开发团队进行工作和流程管理,减少产品技术退化的风险。从上面列出的好处中可以明显看出,进行充分的自动化测试非常必要,这可以帮助减轻软件项目中的风险。
自动化测试类型
接下来,我们可以根据实现和运行自动化测试的策略将其分为至少三种不同类型,如下图著名的测试金字塔所示:
从时间和资源使用而言,单元测试的开发及运行成本低,并且单元测试专注于测试与外部依赖项隔离的单个系统组件(例如,业务逻辑)。
集成测试向前更进一步,并且在不隔离外部依赖关系的情况下进行开发和运行。在这种情况下,我们有兴趣评估所有系统组件构建在一起并面临集成约束(例如:联网、存储、处理等)时是否按预期进行交互。
最后,在金字塔的顶端,GUI 测试是整个自动化测试中代价最高的。他们通常依靠 UI 输入/输出脚本以及回放工具来模仿最终用户与系统图形用户界面的交互。
在本文中,我们将重点介绍测试金字塔的基础——单元测试,以及采用单元测试的系统体系结构在构建时的注意事项。
有效单元测试的属性
· 简短——只有一个测试目的
· 简单——设置及拆卸方便
· 快速——可以快速执行
· 标准——遵循严格的约定
理想情况下,单元测试应具有所有上述这些属性,下面将详细说明原因。
如果单元测试不够简短,将很难阅读并理解其目的,确切地说是很难理解测试内容。因此,出于这个原因,单元测试应该有一个明确目标,并且只评估测试一件事,而不是尝试同时执行多个测试目的。这样,当某个单元测试失败时,开发人员将更加轻松快捷地定位问题并进行修复。
如果单元测试需要大量精力来设置他们的测试环境,然后将其拆除,那么开发人员通常会开始质疑,花费在编写这些测试上的时间是否值得。因此,我们需要提供一个编写单元测试的环境,该环境要管理测试上下文的所有复杂性,例如依赖注入,数据预加载,缓存清除等。编写单元测试越容易,开发人员创建它们的动力就越大!
如果执行一组单元测试需要花费大量时间,则开发人员自然会减少执行频率。这里的问题在于拥有如此冗长的单元测试套件变得不切实际,开发人员会跳过运行单元测试或有选择地运行,从而降低了其有效性。
最后,如果测试没有一定的标准,不久之后你的测试套件开始看起来像未拓荒的美国西部一样,编写单元测试所使用的编码风格有时会有所不同,甚至会发生冲突。因此,在整个单元测试的范围内追求系统设计的连贯性在整个系统中都是有价值的。
一旦我们对有效的单元测试的架构达成共识,就可以开始定义提升其性能的系统架构准则,如以下各节所述。
01
软件复杂度
除其他因素以外,软件复杂度还源于系统内组件之间不断增加的交互及其内部状态的演变。随着复杂度的提高,无意识地干扰复杂的组件交互网络的风险也随之增加,这可能导致在代码变更时引入缺陷。
此外,通常情况下,系统的复杂性越高,维护和测试就越困难,这引出第一个(一般)准则:
密切关注软件的复杂度并遵循设计原则来控制它
在提高测试性能的同时管理复杂性的方面,值得一提的一个实践方法是,在系统设计中尽可能采用纯函数和不变性。纯函数是具有以下属性的函数:
· 对于相同的参数,其返回值是相同的(不随局部静态变量,非局部变量,可变引用参数或来自I/O设备的输入的变化而变化)。
· 它的评估测试不会产生副作用(局部静态变量,非局部变量,可变引用参数或I/O流不会因测试受到影响)。
从其属性可以明显看出,纯函数非常适合单元测试。它们的使用也消除了许多补充性实践的需求,这些补充性实践将在以下各节中讨论,以处理大部分为有状态的组件。
不变性起着同等重要的作用。不可变对象是创建后状态无法修改的对象。它们更易于交互和具有可预测性,从而有助于降低系统复杂性,消除全局状态。
02
依赖隔离
按照单元测试定义,单元测试旨在隔离测试各个系统组件,因为我们不希望组件的单元测试结果受到其依赖项的影响。隔离程度会根据被测组件的具体情况以及每个开发团队的偏好而有所不同。我个人不担心隔离轻量级的内部业务类,因为我发现,用功能几乎相同的测试组件替代它们不会显示有什么附加影响。这里的策略可能很简单:
在组件设计中应用依赖反转模式
依赖反转模式(DIP)指出,高级和低级对象都应依赖抽象(例如接口),而不是特定的具体实现。一旦将系统组件从其依赖关系中解耦出来,我们就可以在单元测试的上下文中通过简化的、针对测试的具体实现轻松地替换它们。下面的类图可以展示这种结构:
在此示例中,被测试的组件依赖 Repository
和 FileStore
抽象类。当部署到生产环境中时,我们可能会为 Repository 类注入基于 SQL 的具体实现,并为文件存储组件注入基于 S3 的实现,以便在 AWS Cloud 中远程存储文件。不过,在运行单元测试时,我们将希望注入不依赖外部服务的简化功能实现,例如上图中绿色标记的“In Memory”实现。
03
Mocks vs Fakes
请注意,我没有将这些“in memory”实现称为“mocks”。mocks 指模拟对象,它以有限的受控方式模拟了真实对象的行为。我反对使用模拟对象,而赞成使用完全兼容的“fake”实现,是因为后者为我们提供了编写单元测试的更大灵活性,相比设置模拟对象,它以更加可靠的方式从多个单元测试类中进行重用。
为了更详细地说明,假设我们正在为依赖FileStore
抽象类的组件编写单元测试。在此测试中,该组件将一条记录添加到文件存储中,但并不担心操作是否成功(例如,日志文件),因此我们决定以“虚拟”方式模拟该操作。
现在,假设稍后需求发生变化,并且组件需要确保在继续操作之前通过从文件存储中读取文件来创建文件,从而迫使我们更新模拟的行为以通过测试。然后,想象需求又发生了变化,并且组件需要写入多个文件(例如:每个日志级别对应一个日志文件),而不是只写入一个,从而迫使我们的模拟对象行为再次进行修改。你知道发生了什么吗?我们正在慢慢改进我们的模拟,使其代码更趋近于具体的实现。
更糟糕的是,我们最终可能会在整个代码库中散布数很多独立的,半成品的模拟实现,每个单元测试类对应一个,从而导致测试环境更多的维护工作以及较低的内聚性。
为了解决这种情况,我提出以下准则:
依靠Fakes而不是Mocks来实施单元测试,将其视为一等的公民,并将其组织为可重用的模块
由于 Fake 组件实现了业务行为,因此与设置模拟对象相比,它们本质上是更昂贵的初始投资。但是,它们的长期回报肯定更高,并且更符合有效的单元测试的特性。
04
编码风格
每个自动化测试都可以描述为三步:
· 准备测试环境
· 执行关键操作
· 验证结果
(Given)给定已知的初始状态,(When)然后执行某项操作,(Then)每次操作最终都应产生相同的预期结果,这是非常符合逻辑的思考过程。为了使结果变得不同,必须更改初始状态,或者更改操作实现本身。
你可能对上面用黑体字标出的单词很熟悉。它们代表了一种流行的 Given-When-Then 模式,利用该模式可以编写可读性高以及结构清晰的单元测试代码。这一概念很简单:
为单元测试定义和实施单一标准化的编码风格
Given-When-Then 模式有多种实现方式。其中一个方法是将单元测试方法构造为三种不同的方法。例如,考虑用户的密码强度测试:
代码语言:javascript复制[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);}
复制代码
使用这种方法时,主测试方法变成了对该单元测试的三行描述,即使是非开发人员也可以通过阅读来轻松理解。实际上,单元测试的主方法最终会成为系统行为的低级文档,不仅提供文本描述,还提供了执行代码、调试代码并定位内部问题的可能性。当新开发人员加入团队时,这对于缩短系统架构学习曲线非常有价值。
需要强调一下,在编码风格方面,没有唯一正确的方法。我在上面提供的示例可能会使某些开发人员感到不满,例如,因为代码冗长而令人不悦,不过这没关系。真正重要的是,应该在你的开发团队内部就编码规范约定达成一致,每一位成员应始终坚持按照该规范编写有意义的测试代码。
05
测试上下文管理
单元测试上下文管理是一个讨论不够多的话题。“测试上下文”是指成功运行单元测试所需的整个依赖注入以及初始状态设置。
如前所述,当开发人员花费更少的时间来设置测试上下文环境并腾出时间编写测试用例时,单元测试会更有效。我们从以下观察得出我们的最后一个准则,即大量的测试案例可以共享一些测试上下文:
利用构造器类将测试上下文的构建与单元测试用例的实现分开
这个想法是将测试上下文的构造逻辑封装在构造器类中,并在单元测试类中引用它们。然后,每个上下文构造器负责创建特定的测试方案,并可选择地定义用于使其特定化的方法。
让我们看一下另一个代码示例。假设我们正在开发一个反作弊组件,以检测移动应用程序用户可疑的位置变化。测试上下文构造器可能如下所示:
代码语言:javascript复制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,那么本篇文章可能就没有适合你的最佳策略,因为我有意避开谈论那些复杂的模拟框架,而这些框架正是在遗留项目中将单元测试引入极端耦合代码的可行选择