组件测试:改建遗留系统的起点

在遗留系统中工作,无论是开发新功能,还是对旧功能进行修改,抑或是通过重构以期重拾其往日的雄风,都会面临大量的挑战。这些挑战主要来自于流失的业务知识、失传的技术和腐坏的代码等。一般来说,改建遗留系统通常会先对其添加必要的测试,再开展重构和重新设计等一系列工序,从而提升其内建质量。

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,必要时也可以采用内存中运行的嵌入式数据库,例如 SqlLiteH2 数据库,并且使用数据框架动态地在数据库创建必要的表结构(Schema),Entify Framework Code First 以及 Hibernate 等流行的 ORM 框架均具有这样的能力。对于外部的 HTTP 依赖,同样可以采用临时实现的 Stub 对象,也可以采用社区中流行的 mockhttpClient-driver 这样的工具库。这里,本文也准备了一份简单的示例程序供读者参考,提供了 C# 版本Java 版本 可用。

组件测试在形式上看,是一种单元测试,而从测试范围上看,它又是一种集成测试,在一些场合,我们形象地把它理解为“集成的单元测试”。但它与单元测试的关注点是有所区分的。在编写组件测试的用例时,不要过于关注代码逻辑细节,而应该从业务场景出发关注 Web 应用的行为。比如在一个用户注册的 API 进行测试时,可针对注册 API 成功的场景测试给出的响应是正确的,并给用户发送了一封确认邮件,而不是向 API 提供多个用户名用例并测试哪些用户名是合法的(那些应该由测试用户名验证程序的单元测试覆盖)。

与进程内组件测试相比,进程外的组件测试则直接对部署后的服务进行测试,更具有集成性,但由于进程外的组件测试在运行之前需要对 Web 服务进行部署和启动,因而其成本更大;测试运行时由于需要通过网络调用,所以效率也会相对较低。所以在进程外运行组件测试并没有什么优势。它只是在进程内组件测试无法高效开展时的一种妥协。除非要改建的遗留系统的外部依赖无法高效地基于代码进行设置、不能通过代码在进程内启动,否则应该优先采用进程内的组件测试。

总结

没有人愿意每天与遗留系统为伍,但总有些约束让我们不得不妥协。基于遗留系统开展工作,总是会遇到很多挑战。在实践中,我们发现在遗留系统的改建过程中,组件测试总是能够在我们遭遇困境时,给出令人满意的答案。

在实践组件测试时,如果一开始不能做到在进程内进行组件测试,可以先从进程外开展,而后逐步实现更稳定高效的进程内组件测试。需要注意的是,组件测试在改建遗留系统的过程中,能成为在现时约束下的一种可贵折衷。但它并不能代替其他类型的测试,我们依然需要借助其他类型的测试来对应用进行更完整的保障。组件测试只测试了应用(组件)内部的行为,因而在必要时可能要采用契约测试等方式来关注系统间的交互行为的正确性。在开发新功能时,我们还是要优先考虑成本最小、最利于保障系统设计的单元测试;而在保障业务场景时,必要的端到端界面测试依然是必不可少的。


更多精彩洞见,请关注微信公众号:思特沃克

Share

征服遗留系统

背景

就像晓强在第一个故事开篇所介绍的那样,如今,我们所交付的典型软件已经变成了由若干个Cloud Native Application所组成的分布式的微服务应用,但是在我们所服务的组织中,仍然存在着类似下面的这种巨大的老旧的单体应用,我们称之为遗留系统。

有些遗留系统仍然在组织中扮演着重要的角色,持续为客户提供着价值,而有些则已经成为了组织发展的瓶颈,无法适应业务的快速变化。这个故事便是关于我们是如何帮助客户有效的维护、提升、最终摆脱这些遗留系统的。

提到遗留系统,我们并不陌生也不缺乏案例。过往的大型项目为我们提供了很多值得分享的经典案例,能够从这些经验和总结中感受到在这些项目中所克服的挑战。这些挑战分别来自于:

  • 如何有效的积累遗留系统的上下文
  • 如何对遗留系统进行维护和变更
  • 如何能平滑的完成对遗留系统的技术迁移

积累上下文

万事开头难,当我们开始任何一项交付工作时,最关键的问题便是如何能够快速建立业务和技术上下文。而当我们开始接手一个遗留系统的时候,这个关键问题变得更加复杂和困难。

遗留系统的上下文就和一个没完成的拼图一样,碎片散落一地。有些信息只存在某些人深深的脑海里,有些信息在巨大而且过期的文档里,难辨真伪。代码不会撒谎,但是动辄几百万行的代码很容易让你迷失方向.

在应对这一难题上,我们从以往经验中形成了一套行之有效的方法。

首先,通过沟通和询问客户团队的业务或技术人员去了解到尽可能多的信息,然后通过阅读文档或在可用环境中演示来得到更具象的认识,最终通过阅读代码来解除剩下的疑问,完成巨大拼图中的一部分。

但是在实际操作中确仍然不简单。往往情况是这样的,在经过多次沟通和在文档中查阅信息后,获得的信息往往和代码中的无法对应起来,使整个过程需要不断的反复。我在去年经历的一次遗留系统改造项目中就有一次类似的经历,团队被这个令人沮丧的过程打败,做出了一些基于已有信息的假设,最终给项目的交付造成了不大不小的风险,团队付出了很大的代价才保证了项目的顺利交付。

在获得了上下文后,如何保持信息能得到及时的更新并有效的将信息共享给团队其他人是紧接着需要思考的问题。Specification by Example为我们提供了很好的方式将所有信息有效的管理起来,构建一套和代码一起管理的可执行的Live Document。但是对于一个遗留系统,这仍然是一个漫长和繁琐的过程。在这整个信息收集和记录的过程中,团队需要展现强大的耐心才能有效的达成目标为后面的工作打下基础。

开展变更

对于有些遗留系统我们只需要对其持续的监控,保证其能够正常的提供服务。但是在大多数情况下随着客户的业务不断变化,也会产生对遗留系统进行变更的需求,来迎合这些业务上的变化。那么如何在不破坏遗留系统的前提下修改遗留系统便成了应对遗留系统的第二个挑战。

用我们所推崇和坚持的一系列敏捷技术实践可以为遗留系统变更提供一张很好的保护网。

  • 在进行对遗留系统的修改工作之前,通过一定的单元测试覆盖,加上之前我们已经建立好的Live Document,能够为我们很好的提供质量保证。
  • 通过建立针对遗留系统的CI/CD Pipeline可以使我们在修改遗留系统时快速的得到反馈,对变更进行及时的验证。
  • 通过创建Stubs来Mock遗留系统的外部依赖则能帮助我们有效的缩短反馈环,可以大大增强我们对遗留系统进行变更的信心。

这些实践看起来和我们所交付的其他项目没有两样,但是当你需要为某个老旧语言编写的遗留系统提供单元测试覆盖的时候,当你的CI Pipeline需要支持一个老旧的商业中间件的自动化部署的时候,看似普通的技术实践则会变得困难重重。

这个时候将坚持这些实践作为原则变得尤为重要。这样才能为遗留系统的变更提供有效的保障。

技术迁移

当然仅凭耐心和原则是无法征服动辄几百万行代码的庞然大物的。应对遗留系统对技巧有着更高的要求。在这方面,我的ThoughtWorks同事们已经从过去的项目经历中总结和分享了很多应对遗留系统的技巧。特别是在对遗留系统进行技术迁移的过程中。比如:

  1. 影响结构图与特征草图的使用,帮助我们去梳理程序中各个模块之间的关系和依赖。
  2. Branch By Abstraction的使用,使我们可以逐渐的替换将系统中遗留的部分更新并剔除出去。
  3. Strangler Pattern的使用,让新老系统在一定的时间段内共存,使遗留系统能够平滑的迁移到新的技术架构。
  4. Feature Toggle的使用使我们能在部署后发现异常时快速的切换回老系统,使迁移风险降到了最低。
  5. 针对遗留系统的数据特点建立自定义的数据管道,完成遗留系统数据的迁移。

正是对这些技巧的灵活使用使我们真的做到了“旧的不变,新的创建,一步切换,旧的再见”。

写在最后

遗留系统是个难题,在应对一个巨大的遗留系统时没有捷径,同时也没有神奇的秘籍或令人目眩的黑科技。重要的是,团队需要意识到在面对一个遗留系统的时候我们需要具备:

更强大的耐心 – 去有效的收集和巩固遗留系统漫长发展过程中遗失的上下文。

更坚定的原则 – 去坚持敏捷技术实践,为遗留系统编织可靠的保护网给遗留系统的变更提供保障。

更丰富的技巧 – 去最大程度降低遗留系统技术迁移过程中对现有业务的影响,逐步平滑的完成遗留系统的迁移。


更多精彩内容,请关注微信公众号:软件乌托邦

Share

一个遗留系统自动化测试的七年之痒

背景

项目从2009年开始启动,采用的是TDD开发方式。在这之后的过程中,团队做过各种尝试去调整自动化测试的策略去更好的适应不同阶段项目的特征,比如调整不同类型测试的比例,引入新的测试类型等。

七年之痒 – 痛点

随着项目走到了第七个年头,一系列的变化在不断发生,比如技术上引入了微服务、EventStore等,业务变得越来越复杂,子系统变得更多,更多的人员加入,开始实施按月发布等,这些因素交织在一起凸显出自动化测试的滞后。首先是从团队成员感知到的一些痛点开始的:

质量下降 – 这个体现在部署到测试环境的代码质量较差,常常就是新版本部署上去之后某个核心功能被破坏,要么是新功能破坏了老功能,要么是bug的修复把其他功能破坏。

测试不稳定 – QA有很长的时间在等待修复或新功能提交出包,而这个等待可能是几个小时也有可能是几天。除去网络问题、部署流水线的复杂性等因素,自动化测试的不稳定性也导致出包的速度也受到了影响。大家往往更关注于怎么能把测试通过了能够出一个包,却忽略了我们该怎么去处理一个不稳定的测试。下图的run2, run3正是大家在不断的尝试去rerun挂掉的测试。

1-rerun

团队越来越忙,开始陷入恶性循环 – 随着功能的逐渐增多,每个月上线的回归测试列表越来越长,QA需要花更多的时间去做重复的回归测试,新功能的测试和回归测试的压力都很大,甚至有的时候根本都没有时间去review下一个阶段的需求,更别提其他一些更有价值的事情。往往回归测试做不完就不得不往下一个阶段推,这种不断往复导致大家对发布的产品信心严重不足。

在这种情况下,自动化测试的有效性和完备性都受到了质疑。本来期望自动化测试能够帮助我们构建一张安全的防护网,保证主干业务不被破坏;随着pipeline频繁的去执行,及时反馈问题,不要等到测试环境才暴露出来;同时能够把QA重复的手工测试时间释放出来,去做一些更有价值的事。可是根据团队所感受到的痛点,我们觉得自动化测试不仅没有帮助到我们,反而在一定程度上给团队带来了干扰。

问题分析

自动化测试到底出了什么问题?我们从现有UI测试入手开始分析,发现了以下典型现象:

  1. 最有价值的场景没有被覆盖
    虽然有较多的测试场景,但是体现出核心业务价值的场景却稀少。我们都知道80/20原则,用户80%的时间在使用系统中20%的功能,如果大部分的UI测试是在测另外那80%的功能,这样一个覆盖给团队带来的安全指数是很低的。
  2. 失效的场景
    功能已经发生了变化,可是对应的UI测试并没有变,至于它为什么没有挂掉,可能有一些侥幸的因素。比如现在点了确认按钮之后新增了弹窗,而测试并没有关掉弹窗,而是通过URL跳转到了别的页面,也没有验证弹窗的新功能是否工作,既有的实现方式确实会使得测试一直通过,但是没有真的验证到正确的点。
  3. 重复的测试
    同样的测试在UI层跟API层的重合度较高,有的甚至是100%。比如搜索用户的功能,分别去按照姓、名、姓的一部分、名的一部分、姓+名等等各种组合去验证。我们不太清楚在当时是基于怎样的考虑留下了这么多跟UT/API测试重复的用例,但是在现阶段分析之后我们觉得这是一种没必要的浪费。 另外,不同的测试数据准备都是UI测试执行出来的,很多场景都用到了相同的步骤,我们觉得这也是一种重复,可以通过其他方式来实现。

解决问题

这些问题从某种角度上都暴露出了UI测试年久失修,没有得到好的维护,而新功能的自动化测试又在不断重蹈覆辙,问题积攒到一起暴发出来使得大家开始重视自动化测试。 有痛点并且找到问题了,下一步就是解决问题。我们分了两步来走,第一个就是对已有UI测试的优化,第二个是对新功能的自动化测试策略的调整。

已有UI测试的优化

针对已有的自动化测试,再回过头逐个去审查UT/API/UI测试代价太高,我们只能是从UI测试优化入手。针对前面提到的3个问题我们逐一去攻克:

  • 识别系统关键业务场景
    我们首先挖掘出系统中用户要达到的各个业务目标,根据不同的业务目标梳理出不同的业务场景,然后将这些发给客户去评审。客户对我们总结出的这些场景很认可,只是把每个业务目标都赋予了一个优先级,为后续的编码实现给予了参考。
  • 重新设计测试场景
    UI测试不是着重去测试某个功能是否工作,更关注的是用户在使用系统时能否顺利实现某个业务目标,因此我们需要知道用户是怎么使用系统的。同样的目标,可能会有多个途径来完成,通过跟客户的访谈以及观察产品环境下页面的访问频度,我们重新规划了测试场景,期待能更贴近用户的真实行为,及时防御可能会导致用户不能顺利完成业务目标的问题。
  • 优化测试数据准备,删除重复的测试
    对于UI层的过度测试,直接删除和API/UT层重复的测试,保留一条主干路径用来测试系统连通性。 对于不同的业务场景可能需要准备的数据,我们舍弃了之前通过UI执行测试这种成本高的方式,转而以发API请求的方式来准备,这样降低了测试执行的时间,也使得测试更加的稳定。 重新梳理的测试场景帮我们构建了一张较为全面的安全防护网,覆盖了绝大部分的用户使用场景,大家的信心显著提高。如果核心业务受到破坏,立马就可以通过UI测试反馈给相关的人。执行更加稳定的测试也减少了大家对UI测试是否能真的发现问题的质疑,因为随机挂的频率大大的降低,一旦挂可能就是真的有bug了。

新功能自动化测试策略的调整

我们一般会以测试金字塔作为自动化测试的指导策略,下图是我们项目的测试金字塔。

2-test-pyramid

为了避免新功能步旧功能的后尘,我们对自动化测试策略进行了调整。除去以不同层的数量分布来判定策略是否合理外,我们也更看重在这个数量下关键业务场景是否能被有效地覆盖,主要通过下面两种方式来保证:

  1. QA及早介入自动化测试
    质量是需要内建的,不是测出来的。QA从一开始就介入整个流程之中,在story启动的时候会和DEV一起准备任务拆分。在后期验收story的同时也会验收单元测试,确保能在UT/API/Contract层实现的测试都在这些层面覆盖,不仅保证了底层测试的数量要够多,也确保了这么多测试覆盖的点都是合理有效的。在这个过程中,QA把更多的测试思路传递给团队成员,引发大家更多的从质量角度去思考。
  2. QA与DEV结对写UI测试
    最后在整个功能做完的时候,QA也会和DEV结对实现UI测试,涉及到现有测试场景的维护与更新。基于前面对底层测试的review,大家对于整个功能的测试覆盖都有了一定程度的了解,对于UI测试要测得点也会较快的达成一致。另外,QA在与DEV结对实现UI测试的时候,编码能力也得到了提高。

在推动QA更多参与底层测试的过程中,我们更多的从测试角度去影响团队,增加了团队的质量意识。QA的时间被释放出来了,去做了更多有价值的事,比如探索性测试,Log监控与分析,安全测试,产品环境下用户行为分析等。这一些列活动的影响就是产品的质量顺便得到了提升。

总结

等我们把已有功能UI测试优化完,新功能的自动化测试策略开始落实到全组,已经是半年以后的事了。我们慢慢的感受到一切都在回归正轨,之前的痛点在逐步消去,团队交付的节奏也越来越顺畅,对发布产品的信心也更强了。

回顾这个遗留系统的自动化测试优化过程,我们有一些收获:

  1. 大家说到UI测试往往更倾向于如何编码实现,但我们希望开始UI测试的时候能多关注下测试用例的设计是否合理,是不是能够体现出业务价值。
  2. UI测试的用例和代码都是测试资产,需要跟产品代码等同对待,不能写出来就不管不顾,没有维护是不可取的。
  3. 自动化测试不仅仅是UI测试,需要和UT/API等其他底层测试一起分工合作,作为测试策略的一部分来为产品质量保驾护航。
Share