在遗留系统中工作,无论是开发新功能,还是对旧功能进行修改,抑或是通过重构以期重拾其往日的雄风,都会面临大量的挑战。这些挑战主要来自于流失的业务知识、失传的技术和腐坏的代码等。一般来说,改建遗留系统通常会先对其添加必要的测试,再开展重构和重新设计等一系列工序,从而提升其内建质量。
Martin Fowler 在微服务的测试策略的分享中,详细讨论了各种测试方法及其适用场景。在该讨论中,他介绍了组件测试:
组件是在大型系统中封装良好的、可独立替换的中间子系统。对这样的组件进行单独的测试有很多好处,通过将测试范围限制在组件之内,就能在对组件所封装的行为进行验收测试的同时,维持相较于高层测试更好的执行效率。在微服务架构中,组件也就是服务本身。 |
Martin Fowler 还按照测试时调用组件的方式,以及在对组件所依赖的存储或服务构建测试替身时,测试替身位于进程内部还是进程外部来把组件测试分为进程内和进程外两种形态。
实践中,为遗留系统添加单元测试和端到端的界面测试都会遇到其对应的困难,而我们发现组件测试却能由于其关注行为的特点在单元测试和端到端测试之间取得平衡,对于改建遗留系统来说,它提供了一个不错的起点。
避开单元测试实践的被动
遗留系统从最初发布到现在,早已过去多年,当初的开发人员早已离开,徒留一段代码给后来者。在遗留系统上的工作通常要求不能破坏现有其他功能,只能按要求“恰好”地修改。作为敏捷开发人员,***步的计划就是使用单元测试来保障已有功能不被破坏。但团队很快就会发现遗留系统使用的技术失传已久,新的团队中基本没人了解,要基于这样的技术来构建单元测试寸步难行。对于一个没有任何自动化测试的老旧系统来说,往往也意味着其内部设计耦合度之高,想理解清楚就已经很吃力了,更遑论可测试性。
在下面的示例代码里,我们无法方便地对其中的 StockService 中所依赖的 WebClient 实例进行模拟,从而无法对 GetUpdatedStock 的功能进行测试:
- public class StockService {
- public IEnumerable<Stock> GetUpdatedStock(){
- var stockContent = new WebClient().DownloadString("https://stocks.com/stocks.json");
- var stocks = JsonConvert.DeserializeObject<List<StockResponse>>(stockContent);
- return stocks.Select(ToStock);
- }
- private static Stock ToStock(StockResponse resp)
- {
- // 对象转换逻辑略
- }
- }
另一方面,在老旧系统上的开发工作,往往也意味着接下来需要对其进行较大规模的重构,以利于更好的可维护性,更轻松地添加新功能。在这种背景之下,即使为系统添加了单元测试,接下来的重构又会使得细粒度的单元测试成为一种浪费——重构势必要修改代码设计,导致单元测试也需要跟着一起修改。
相比于单元测试的矛盾,组件测试关注 Web 应用本身的功能和行为,而不是其中某个单独的层次。实际上,很多遗留系统甚至连清晰的层次化设计都没有。组件测试对 Web 应用公开的 API 或 Web 页面源码测试,在避免陷入代码细节设计不良带来的被动局面的同时,能够保障 Web 应用的行为的正确性,而这也正是我们为遗留系统添加单元测试想保障的。组件测试关注的是业务行为,而不是代码实现细节。因此不会随着代码实现细节的变化而受到影响。所以组件测试不会限制重构手法的施展,也不会在调整设计时带来额外的修改测试的负担,相反它却可以给重构提供有力的保障,帮助确保重构的安全性。
绕过端到端界面测试的窘境
在改建遗留系统开展的实践中,不少团队为了摆脱单元测试的被动局面,尝试过为其添加端到端界面自动化测试的策略。这样几乎可以完全忽略代码细节,而直接关注业务场景。相对来说,只需要能做到自动地部署 Web 应用和必要的依赖(比如数据库),就可以对应用开展测试了。但实际执行过程中,团队发现要为老旧系统构建这样的一种环境,并不容易。端到端集成测试需要在真实的 Web 应用程序实例上运行测试,并且要求各项基础设施也尽可能地真实,包括数据库、缓存设施等。因此,要想让端到端的集成测试在持续集成环境自动地运行,就要求应用程序及其所依赖的基础设施有自动化部署的能力。老旧系统往往自动化程度很低,无法自动完成部署以开展端到端集成测试。即使 Web 应用本身的部署并不复杂,它依赖的其他服务也很难自动地部署,比如 SMTP 服务器等。
在测试金字塔中,端到端测试界面测试位于较高的层次。这意味着即使成功地构建了自动化的环境,还是会由于测试所依赖的资源较多,造成测试成本相对较高的状况。由于端到端测试集成了系统的多个层次,测试用例的运行也就比低层次的测试用例更脆弱,而运行速度也会更慢。
这些挑战和特点决定了我们很难在短时间里为遗留系统添加足够的端到端界面测试案例以保障接下来的改建工作。在开展组件测试时,则完全不需要担心端到端测试的这些问题。组件测试通过一定的方法模拟并隔离 Web 应用的外部依赖,避免了复杂的部署和配置外部依赖的过程。更小型、专用的模拟层的启动和运行速度都可以根据测试的需求来定制;如果采用进程内的组件测试,更是可以进一步提高测试案例的运行效率与稳定性。
组件测试***实践
把 Web 应用本身看作单元测试中的被测试的单元,将 Web 应用的外部依赖都用测试替身进行模拟和隔离,并按业务场景测试组件中提供的 API 或 Web 页面的行为,即为组件测试。在进程内组件测试的实践方法中,我们直接在测试代码中自动地构建 Web 应用所需的依赖项,启动被测试的服务,然后调用要测试的 API 并执行断言。下面的代码演示了这样的测试的大体流程:动态地创建一个关系型数据库,启动 Web 应用,利用 Web 应用中 repository 的准备测试所需的数据,然后调用被测试的接口并对结果进行断言。
- [Fact]
- public async void should_handle_search_request_with_mocked_database()
- {
- var sqliteConnection = DatabaseUtils.CreateInMemorryDatabase(out var databaseOptions);
- var appServices = SetupApplication(sqliteConnection, out var client);
- var jim = new Employee {Id = 12, Name = "Jim"};
- appServices.GetService<IRepository<Employee>>().Save(jim);
- var response = await client.GetAsync("/employees/search/im");
- var employeeString = await response.Content.ReadAsStringAsync();
- Assert.Equal("Jim(id=12)", employeeString);
- }
在进程内运行的组件测试,可以选择以合适的方式对 Web 应用的依赖进行模拟。以数据访问层为例,我们可以直接对 DAO 类进行模拟,也可以在需要测试事务支持的时候为测试构建真实数据库实例,并在测试运行结束时清理这些临时创建的资源。既能享受上文所述的行为测试的稳定性,又可以获得代码级模拟的灵活性。
具体地,由于要在测试代码中按需启动应用程序,这对 Web 应用程序的基础设施提出一些要求。如果我们基于 ASP.NET WEB API 或者 Spring Boot 等框架开发应用,那么框架就已经提供了这种能力。对数据层进行模拟时,在简单的情形中可以采用内存重新实现的 RepositoryStub,必要时也可以采用内存中运行的嵌入式数据库,例如 SqlLite 和 H2 数据库,并且使用数据框架动态地在数据库创建必要的表结构(Schema),Entify Framework Code First 以及 Hibernate 等流行的 ORM 框架均具有这样的能力。对于外部的 HTTP 依赖,同样可以采用临时实现的 Stub 对象,也可以采用社区中流行的 mockhttp、Client-driver 这样的工具库。这里,本文也准备了一份简单的示例程序供读者参考,提供了 C# 版本和 Java 版本 可用。
组件测试在形式上看,是一种单元测试,而从测试范围上看,它又是一种集成测试,在一些场合,我们形象地把它理解为“集成的单元测试”。但它与单元测试的关注点是有所区分的。在编写组件测试的用例时,不要过于关注代码逻辑细节,而应该从业务场景出发关注 Web 应用的行为。比如在一个用户注册的 API 进行测试时,可针对注册 API 成功的场景测试给出的响应是正确的,并给用户发送了一封确认邮件,而不是向 API 提供多个用户名用例并测试哪些用户名是合法的(那些应该由测试用户名验证程序的单元测试覆盖)。
与进程内组件测试相比,进程外的组件测试则直接对部署后的服务进行测试,更具有集成性,但由于进程外的组件测试在运行之前需要对 Web 服务进行部署和启动,因而其成本更大;测试运行时由于需要通过网络调用,所以效率也会相对较低。所以在进程外运行组件测试并没有什么优势。它只是在进程内组件测试无法高效开展时的一种妥协。除非要改建的遗留系统的外部依赖无法高效地基于代码进行设置、不能通过代码在进程内启动,否则应该优先采用进程内的组件测试。
总结
没有人愿意每天与遗留系统为伍,但总有些约束让我们不得不妥协。基于遗留系统开展工作,总是会遇到很多挑战。在实践中,我们发现在遗留系统的改建过程中,组件测试总是能够在我们遭遇困境时,给出令人满意的答案。
在实践组件测试时,如果一开始不能做到在进程内进行组件测试,可以先从进程外开展,而后逐步实现更稳定高效的进程内组件测试。需要注意的是,组件测试在改建遗留系统的过程中,能成为在现时约束下的一种可贵折衷。但它并不能代替其他类型的测试,我们依然需要借助其他类型的测试来对应用进行更完整的保障。组件测试只测试了应用(组件)内部的行为,因而在必要时可能要采用契约测试等方式来关注系统间的交互行为的正确性。在开发新功能时,我们还是要优先考虑成本最小、最利于保障系统设计的单元测试;而在保障业务场景时,必要的端到端界面测试依然是必不可少的。
【本文是51CTO专栏作者“ThoughtWorks”的原创稿件,微信公众号:思特沃克,转载请联系原作者】