用于数百种客户端版本的Monolith:我们如何编写和支持测试



大家好!

我是Badoo服务器团队的后端开发人员。 在去年的HighLoad会议上,我做了一个演示文稿 ,希望与您分享其文本版本。 对于那些自己为后端编写测试并遇到测试遗留代码问题的人,以及那些想测试复杂业务逻辑的人,这篇文章将是最有用的。

我们要谈什么? 首先,我将简要讨论我们的开发过程以及它如何影响我们对测试的需求以及编写这些测试的愿望。 然后,我们将上下讨论测试自动化的金字塔,讨论我们使用的测试的类型,讨论每个测试中的工具以及在帮助下解决哪些问题。 最后,请考虑如何维护和运行所有这些东西。

我们的发展过程


我们已经说明了我们的开发过程:


高尔夫球手是后端开发人员。 在某个时候,一项开发任务通常以两个文档的形式移交给他:业务方面的要求和描述后端与客户端(移动应用程序和站点)之间交互协议更改的技术文档。

开发人员编写代码并将其投入运行,并且比所有客户端应用程序更早。 所有功能均受某些功能标志或A / B测试的保护,这在技术文档中有规定。 之后,根据当前优先级和产品路线图,发布客户端应用程序。 对于我们后端开发人员来说,何时在客户端上实现特定功能是完全不可预测的。 客户端应用程序的发布周期比我们的发布周期复杂一些,并且更长,因此我们的产品经理从字面上考虑了优先事项。

公司采用的开发文化非常重要:后端开发人员负责此功能,从后端实施该功能到最初计划实施该功能的最后一个平台上的最后集成。

这种情况很可能发生:六个月前,您推出了某些功能,客户团队很久没有实施它,因为公司的优先级已发生变化,您已经在忙于其他任务,您有新的截止日期和优先级-在这里您的同事开始运转并他们说:“您还记得六个月前冲掉的东西吗? 她不在工作。” 而不是承担新任务,而是扑灭大火。



因此,对于PHP程序员来说,我们的开发人员具有非同寻常的动机-确保在集成阶段尽可能减少问题。

您首先要做什么以确保该功能正常工作?

当然,首先想到的是进行手动测试。 您选择了该应用程序,但它不知道如何-因为该功能是新功能,所以客户将在六个月内对其进行处理。 好吧,手动测试不能保证从后端发布到集成开始所经历的时间内,没有人会破坏客户端。

在这里,自动化测试对我们有帮助。

单元测试


我们编写的最简单的测试是单元测试。 我们使用PHP作为后端的主要语言,并使用PHPUnit作为单元测试的框架。 展望未来,我会说我们所有的后端测试都是在此框架的基础上编写的。

单元测试我们通常会覆盖一些孤立的小代码,检查方法或函数的性能,也就是说,我们所讨论的是业务逻辑的微小单元。 我们的单元测试不应与任何东西交互,访问数据库或服务。

软弹


开发人员在编写单元测试时面临的主要困难是无法测试的代码,这通常是遗留代码。

一个简单的例子。 Badoo是一家由几人共同开发的小型初创公司,成立于12岁。 该启动公司非常成功地存在,根本没有进行任何测试。 然后我们变得足够强大,意识到没有测试就无法生存。 但是到了此时,已经编写了很多行之有效的代码。 不要仅仅为了测试而重写它! 从业务角度来看,这将不是很合理。

因此,我们开发了一个小的开源库SoftMocks ,这使我们编写测试的过程更便宜,更快。 它拦截所有包含/需要的PHP文件,并用修改后的内容(即重写的代码)即时替换源文件。 这使我们可以为任何代码创建存根。 详细说明了库的功能。

这对开发人员来说是这样的:

//mock  \Badoo\SoftMocks::redefineConstant($constantName, $newValue); //mock  : , ,  \Badoo\SoftMocks::redefineMethod( $class, $method, $method_args, $fake_code ); //mock  \Badoo\SoftMocks::redefineFunction( $function, $function_args, $fake_code ); 

借助这种简单的构造,我们可以在全球范围内重新定义所需的一切。 特别是,它们使我们能够规避标准PHPUnit制造商的限制。 也就是说,我们可以模拟静态和私有方法,重新定义常量并做更多的事情,这在普通的PHPUnit中是不可能的。

但是,我们遇到了一个问题:在开发人员看来,如果有SoftMocks,则无需编写经过测试的代码-您可以随时使用我们的全局模拟“对”代码进行梳理,并且一切都会正常进行。 但是这种方法导致更复杂的代码和“拐杖”的积累。 因此,我们采用了一些规则以使我们能够控制局势:

  1. 所有新代码都应使用标准PHPUnit模拟轻松测试。 如果满足此条件,则代码是可测试的,您可以轻松地选择一小段代码并仅对其进行测试。
  2. SoftMocks可以与以不适合单元测试的方式编写的旧代码一起使用,以及在过于昂贵/太长/否则难以完成的情况下使用(强调必要的内容)。

在代码审查阶段,将严格监控对这些规则的遵守情况。

变异测试


另外,我想谈一谈单元测试的质量。 我认为你们中的许多人都使用诸如代码覆盖率之类的指标。 但是,不幸的是,她没有回答一个问题:“我编写了一个好的单元测试吗?” 您可能编写了这样的测试,该测试实际上不检查任何内容,不包含单个断言,但是可以产生出色的代码覆盖率。 当然,示例被夸大了,但是情况离现实还很远。

最近,我们开始引入突变测试。 这是一个相当古老的概念,但不是很知名。 这种测试的算法非常简单:

  • 获取代码和代码覆盖范围;
  • parsim并开始更改代码:true到false,>到> =,+到-(通常,以各种方式伤害);
  • 对于每个这样的突变更改,请运行涵盖更改后的字符串的测试套件;
  • 如果测试失败,那么它们是好的,并且确实不允许我们破坏代码;
  • 如果测试通过了,很可能尽管涵盖了测试范围,但效果仍然不够好,因此有必要更仔细地研究它们,以断言(或测试没有涉及的领域)。

有几个现成的PHP框架,例如Humbug和Infection。 不幸的是,它们不适合我们,因为它们与SoftMocks不兼容。 因此,我们编写了自己的小控制台实用程序,该实用程序执行相同的操作,但是使用内部代码覆盖格式,并且与SoftMocks成为朋友。 现在,开发人员手动启动它并分析他编写的测试,但是我们正在努力将该工具引入我们的开发过程。

整合测试


借助集成测试,我们可以检查与各种服务和数据库的交互。

为了进一步了解这个故事,让我们开发一个虚构的电视节目预告片,并进行测试。 想象一下,我们的产品经理决定将会议门票分发给我们最专注的用户:


促销应在以下情况下显示:

  • 用户在“工作”字段中表示“程序员”,
  • 用户参加了A / B测试HL18_promo,
  • 该用户已注册两年多了。

通过单击“获取工单”按钮,我们必须将该用户的数据保存到某个列表中,以便将其传输给分发工单的经理。

即使在这个非常简单的示例中,也存在无法使用单元测试来验证的事情-与数据库的交互。 为此,我们需要使用集成测试。

考虑一下PHPUnit提供的测试数据库交互的标准方法:

  1. 提升测试数据库。
  2. 我们准备数据表和数据集。
  3. 运行测试。
  4. 我们清除测试数据库。

用这种方法等待有什么困难?

  • 您需要支持数据表和数据集的结构。 如果我们更改了表格布局,则有必要在测试中反映这些更改,这并不总是很方便,并且需要额外的时间。
  • 准备数据库需要时间。 每次设置测试时,我们都需要在那里上传一些内容,创建一些表,如果有很多测试,这将很麻烦。
  • 最重要的缺点是:并行运行这些测试会使它们不稳定。 我们开始测试A,他开始写到他创建的测试表中。 同时,我们启动了测试B,该测试希望与同一个测试表一起使用。 结果,出现相互阻塞和其他不可预见的情况。

为了避免这些问题,我们开发了自己的小型库DBMocks。

DBMocks


操作原理如下:

  1. 在SoftMocks的帮助下,我们拦截了所有与数据库一起使用的包装器。
  2. 什么时候
    该查询通过模拟,解析SQL查询并从中提取DB + TableName,并从连接中获取主机。
  3. 在tmpfs的同一主机上,我们创建一个临时表,其结构与原始表相同(我们使用SHOW CREATE TABLE复制该结构)。
  4. 之后,我们会将所有通过模拟传递给该表的请求重定向到一个新创建的临时请求。

这给我们带来了什么?

  • 无需经常照顾结构;
  • 测试不能再破坏源表中的数据,因为我们可以将它们动态地重定向到临时表;
  • 我们仍在测试与正在使用的MySQL版本的兼容性,如果请求突然停止与新版本兼容,则我们的测试将看到并使其崩溃。
  • 最重要的是,现在测试是隔离的,即使您并行运行它们,线程也将分散到不同的临时表,因为我们在测试表的名称中添加了每个测试唯一的键。

API测试


此GIF很好地说明了单元测试和API测试之间的区别:


锁工作正常,但已连接到错误的门上。

我们的测试模拟了一个客户端会话,能够按照我们的协议将请求发送到后端,并且后端将其作为真实客户端进行响应。

测试用户池


成功编写此类测试需要什么? 让我们回到促销节目的展示条件:

  • 用户在“工作”字段中表示“程序员”,
  • 用户参加了A / B测试HL18_promo,
  • 该用户已注册两年多了。

显然,这里一切都与用户有关。 实际上,99%的API测试都需要一个授权的注册用户,该用户存在于所有服务和数据库中。

在哪里得到的? 您可以在测试时尝试注册它,但是:

  • 它很长而且很耗资源;
  • 完成测试后,需要以某种方式删除此用户,如果我们正在谈论大型项目,这是一项相当艰巨的任务;
  • 最后,就像在许多其他高负荷项目中一样,我们在后台执行许多操作(将用户添加到各种服务,复制到其他数据中心等); 测试对这些过程一无所知,但是如果它们隐式地依赖于执行结果,则存在不稳定的风险。


我们开发了一个名为“测试用户池”的工具。 它基于两个想法:

  1. 我们不会每次都注册用户,但是会多次使用它。
  2. 测试后,我们将用户数据重置为原始状态(在注册时)。 如果不这样做,随着时间的流逝,测试将变得不稳定,因为用户将被其他测试的信息“污染”。


它的工作原理如下:



在某个时候,我们想在生产环境中运行我们的API测试。 我们为什么还要这个? 因为开发基础设施与生产不同。

尽管我们一直在努力以减小的规模不断重复生产基础架构,但是devel永远不会是其完整版本。 为了绝对确保新版本符合期望并且没有问题,我们将新代码上载到预生产集群中,该集群可用于生产数据和服务,并在此处运行我们的API测试。

在这种情况下,考虑如何将测试用户与真实用户隔离非常重要。

如果测试用户开始真正出现在我们的应用程序中,将会发生什么。


如何隔离? 我们每个用户都有一个is_test_user标志。 在注册阶段,它变为yesno ,并且不再更改。 通过此标志,我们隔离了所有服务中的用户。 同样重要的是,我们应将测试用户排除在业务分析和A / B测试结果之外,以免扭曲统计数据。

您可以采用一种更简单的方式进行操作:首先,我们将所有测试用户都“迁移”到了南极洲。 如果您有地理服务,这是一种完全可行的方法。

质量检查API


我们不仅需要用户-我们还需要具有特定参数的用户:以程序员的身份工作,参加特定的A / B测试并于两年前注册。 对于测试用户,我们可以使用我们的后端API轻松分配专业,但是进入A / B测试是概率性的。 而且,两年多以前的注册条件通常很难实现,因为我们不知道用户何时出现在池中。

为了解决这些问题,我们有一个质量检查API。 实际上,这是一个测试的后门,这是一个有据可查的API方法,可让您快速轻松地管理用户数据并绕过我们与客户通信的主要协议来更改其状态。 这些方法由后端开发人员为质量检查工程师编写,并用于UI和API测试。

QA API仅适用于测试用户:如果没有相应的标志,则测试将立即失败。 这是我们的质量检查API方法之一,可让您将用户注册日期更改为任意一种:



因此,看起来就像三个调用,它们将允许您快速更改测试用户的数据,以使其满足显示促销的条件:

  • 在“工作”字段中,指示“程序员”:
    addUserWorkEducation?user_id=ID&works[]=Badoo,

  • 用户参加了A / B测试HL18_promo:
    forceSplitTest?user_id=ID&test=HL18_promo
  • 两年多以前注册:
    userCreatedChange?user_id=ID&created=2016-09-01


由于这是一个后门,因此必须考虑安全性。 我们以多种方式保护我们的服务:

  • 在网络级别隔离:只能从办公室网络访问服务;
  • 对于每个请求,我们都会传递一个秘密,没有这个秘密,就不可能从办公室网络访问QA API;
  • 方法仅适用于测试用户。


远程模拟


要使用API​​测试的远程后端,我们可能需要模拟。 为了什么 例如,如果生产环境中的API测试开始访问数据库,我们需要确保清除其中的数据。 此外,模拟有助于使测试响应更适合于测试。

我们有三个文本:



Badoo是一种多语言应用程序,我们有一个复杂的本地化组件,使您可以快速翻译和接收用户当前位置的翻译。 我们的本地化人员一直在努力改善翻译,使用令牌进行A / B测试以及寻找更成功的格式。 而且,在进行测试时,我们无法知道服务器将返回哪些文本-它可以随时更改。 但是我们可以使用RemoteMocks来检查是否正确访问了本地化组件。

RemoteMocks如何工作? 该测试要求后端为会话初始化它们,并在收到所有后续请求后,后端检查当前会话的模拟。 如果是这样,则只需使用SoftMocks对其进行初始化。

如果要创建远程模拟,请指出需要替换的类或方法以及替换的内容。 考虑到以下模拟,将执行所有后续后端请求:

 $this->remoteInterceptMethod( \Promo\HighLoadConference::class, 'saveUserEmailToDb', true ); 

好吧,现在让我们收集我们的API测试:

 //       $app_startup = [ 'supported_promo_blocks' => [\Mobile\Proto\Enum\PromoBlockType::GENERIC_PROMO] ]; $Client = $this->getLoginedConnection(BmaFunctionalConfig::USER_TYPE_NEW, $app_startup); //  $Client->getQaApiClient()->addUserWorkEducation(['Badoo, ']); $Client->getQaApiClient()->forceSplitTest('HL18_promo'); $Client->getQaApiClient()->userCreatedChange('2016-09-01'); //     $this->remoteInterceptMethod(\Promo\HighLoadConference::class, 'saveUserEmail', true); //,   ,   $Resp = $Client->ServerGetPromoBlocks([]); $this->assertTrue($Resp->hasMessageType('CLIENT_NEXT_PROMO_BLOCKS')); $PromoBlock = $Resp->CLIENT_NEXT_PROMO_BLOCKS; … //   CTA, ,   ,   $Resp = $Client->ServerPromoAccepted($PromoBlock->getPromoId()); $this->assertTrue($Resp->hasMessageType('CLIENT_ACKNOWLEDGE_COMMAND')); 


通过这种简单的方法,我们可以测试后端开发中需要更改移动协议的任何功能。

API测试使用规则


一切似乎都很好,但是我们再次遇到了一个问题:API测试对于开发来说太方便了,并且有在所有地方使用它们的诱惑。 结果,一旦我们意识到我们已经开始借助API测试来解决问题,而这些测试并不是我们想要的。

为什么这样不好? 因为API测试非常缓慢。 他们进入网络,转到后端,该后端负责处理会话,数据库和一系列服务。 因此,我们制定了一组使用API​​测试的规则:
  • API测试的目的是检查客户端与服务器之间的交互协议以及新代码的正确集成。

  • 允许用它们涵盖复杂的流程,例如,动作链;
  • 它们不能用于测试服务器响应的微小变化-这是单元测试的任务;
  • 在代码审查期间,我们检查包括测试。

UI测试


由于我们正在考虑自动化金字塔,因此我将向您介绍一些UI测试。

Badoo的后端开发人员不编写UI测试-为此,我们在质量检查部门拥有一支专门的团队。 我们已经想到并稳定了UI测试,从而涵盖了该功能,因为我们认为将资源花费在该功能的相当昂贵的自动化上是不合理的,这也许不会超出A / B测试的范围。

我们将Calabash用于移动自动测试,将Selenium用于网络。 讨论了我们的自动化和测试平台。

试运行


现在,我们有100,000个单元测试,6,000个-集成测试和14,000个API测试。 如果您尝试在一个线程中运行它们,那么即使是在我们功能最强大的计算机上,也需要全部运行:模块化-40分钟,集成-90分钟,API测试-十小时。 太长了

并行化


在本文中,我们讨论了并行化单元测试的经验。

第一个解决方案(似乎很明显)是在多个线程中运行测试。 但是我们走得更远,为并行启动创建了一个云,以便能够扩展硬件资源。 简化后,他的工作如下所示:



这里最有趣的任务是测试在线程之间的分布,即将测试分解为多个块。

您可以将它们平均分配,但是所有测试都不同,因此线程的执行时间可能会有很大的偏差:所有线程已经到达,并且一个挂起半小时,因为测试非常慢,这是“幸运的”。

您可以启动多个线程,并一次向它们提供一个测试。 在这种情况下,缺点不太明显:初始化环境会产生间接费用,在进行大量测试和采用这种方法后,环境开始发挥重要作用。

我们做了什么? 我们开始收集运行每个测试所用时间的统计信息,然后开始以这样的方式组成块:根据统计信息,一个线程运行不超过30秒。 同时,我们将测试紧密地打包在一起以使其更小。

但是,我们的方法也有一个缺点。 它与API测试相关联:它们非常慢并且消耗大量资源,从而阻止了快速测试的执行。

因此,我们将云分为两个部分:第一,仅启动快速测试,第二,可以启动快速和慢速测试。 通过这种方法,我们始终可以处理快速测试的整个过程。



结果,单元测试在一分钟内开始运行,集成测试在五分钟内开始,API测试在15分钟内开始。 也就是说,完整运行(而不是12小时)所花费的时间不超过22分钟。

代码覆盖率测试运行


我们拥有一个庞大的复杂整体,并且以一种好的方式,我们需要不断地运行所有测试,因为一个地方的更改可能破坏另一个地方的某些东西。 这是整体架构的主要缺点之一。

在某个时候,我们得出的结论是,您不必每次都运行所有测试-您可以根据代码覆盖率进行运行:

  1. 以我们的分支差异。
  2. 我们创建一个修改文件列表。
  3. 对于每个文件,我们都会获得测试列表,
    覆盖它。
  4. 通过这些测试,我们创建了一个集合并在测试云中运行它。

在哪里获得保险? 开发环境基础架构空闲时,我们每天收集一次数据。 测试的数量已显着减少,相反,从测试中接收反馈的速度已大大提高。 赢利!

另一个好处是可以运行补丁测试。 尽管Badoo已经很久没有启动了,但我们仍然可以快速实现生产变更,快速注入热修复,推出功能并更改配置。 通常,推出补丁的速度对我们非常重要。 , .

. , , , . . , code coverage . , , — , - , . .

API-, code coverage. , , . - , API- .

结论


  • , . - , , - .
  • ≠ . code review , .
  • , , . .
  • . .
  • , ! , .


, Badoo PHP Meetup 16 . PHP-. , . ! 12:00, — YouTube- .

Source: https://habr.com/ru/post/zh-CN443768/


All Articles