xfyuan
xfyuan A Chinese software engineer living and working in Chengdu. I love creating the future in digital worlds, big and small.

TestProf II:Ruby测试的“工厂疗法”

TestProf II:Ruby测试的“工厂疗法”

本文已获得原作者(Vladimir Dementyev)和 Evil Martians 授权许可进行翻译。原文是 TestProf 这个 Evil Martians 出品的 Gem 介绍文章系列的第二篇。作者介绍了造成 Ruby 慢测试的一个主要元凶——Factory Cascade,以及如何使用 TestProf 来消灭这个元凶。文中也提到了众所周知的 Factories vs. Fixtures 问题,而 TestProf 可以做到让你鱼和熊掌兼得。

【下面是正文】

概述

学习如何把你的 Ruby 测试套件带回到健康满满、速度满满的路上,通过使用 TestProf——一个强大的工具包来诊断所有跟测试有关的问题。这一次,我们来聊聊 factories:它们如何拖慢你的测试,如何来估量那些负面影响,如何避免,以及如何让你的 factories 跟 fixtures 一样快。

前言

TestProf,用于很多 Evil Martians 的项目,以缩短 TDD 的反馈环,对任何其测试运行时间超过一分钟的 Rails(或其他基于 Ruby 的)应用而言都是一个必备工具。它通过扩展有关功能而对 RSpecminitest 均可适用。

在我们展示该开源项目的介绍文章里,当时承诺会有一篇专门文章来讲述在测试 Ruby Web 应用时被经常忽视的一个问题:factory cascades。本文就是我们所承诺的东西。

通过在你的实际测试中运行一下 TestProf 有个概览是比较好的。所以如果你手边恰好有一个 RSpec 覆盖的 Rails 项目,且使用了 factory_bot(之前闻名的名字叫 factory_girl)的 factories——我们建议你阅读下去之前安装该 gem,那么这将会是一个互动式演练!

安装 TestProf 很简单,把下面这行添加到你Gemfile:test组:

1
2
3
group :test do
  gem 'test-prof'
end

Crumbling factories

无论何时要测试自己的应用时,我们都需要生成测试数据——两种常见的方式就是 factoriesfixtures

factory 是一个可以根据预定义 schema 生成其他对象(可以被持久化,也可以不)并动态实现的对象。

Fixtures 相当于一个不同的方案:它们声明数据的静态状态,其会被立即加载到测试数据库中,且通常在测试运行之间保持不变。

在 Rails 世界中,我们有内置 fixtures 和广受欢迎的第三方 factory 工具(比如 factory_bot, Fabrication, 及其他)。

尽管关于 “factories vs. fixtures” 的辩论看起来永远不会停止,不过我们仍然把 factories 视为一种更加灵活也更易于维护的方式来处理测试数据。

然而,能力越大责任越大:factories 更容易让你搬起石头砸自己的脚,让你的测试陷入困境。

那么,我们如何判断自己是否滥用了这种能力并且该怎么办?首先,让我们来看看测试套件耗费在 factories 上的时间有多少。

对此,我们应该使用这个“良医”:TestProf。该 gem 是个完整的诊断工具包,EventProf 为其中工具之一。顾名思义:它是一个事件分析器,可以让它追踪factory.create事件,它会在你每次调用FactoryBot.create()时被触发,同时一个 factory 生成的对象被保存到数据库中。

EventProf 对 RSpecminitest 都支持,有一个命令行界面,所以在任何 Rails 项目目录下启动终端(当然,它必须得有测试和 factories,且本文中所有测试用例都假设用的是 RSpec)并运行如下命令:

1
$ EVENT_PROF="factory.create" bundle exec rspec

在输出中,你可以看到从 factories 创建数据所花费的总时间,以及前五个最慢的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
[TEST PROF INFO] EventProf results for factory.create

Total time: 03:07.353
Total events: 7459

Top 5 slowest suites (by time):

UsersController (users_controller_spec.rb:3) – 00:10.119 (581 / 248)
DocumentsController (documents_controller_spec.rb:3) – 00:07.494 (71 / 24)
RolesController (roles_controller_spec.rb:3) – 00:04.972 (181 / 76)

Finished in 6 minutes 36 seconds (files took 32.79 seconds to load)
3158 examples, 0 failures, 7 pending

从我们项目之一取出的一个真实范例中(重构之前),在六分半的测试运行时间里,超过三分钟是用于生成测试数据,其几乎占据了 50%。这并不令人惊讶:我曾经工作过的一些代码库上,从 factories 生成数据花费了多达 80% 的测试时间。

平静一下,请继续阅读,我们明白如何修复这个问题。

The name of the game is “cascade”

根据多年以来的观测和对 TestProf 的开发,以及对所有与测试有关东西的分析,慢测试的原因里最大的一个是——factory cascade

让我们来做一个演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
factory :comment do
  sequence(:body) { |n| "Awesome comment ##{n}" }
  author
  answer
end

factory :answer do
  sequence(:body) { |n| "Awesome answer ##{n}" }
  author
  question
end

factory :question do
  sequence(:title) { |n| "Awesome question ##{n}" }
  author
  account # suppose it's our tenant in SaaS application
end

factory :author do
  sequence(:name) { |n| "Awesome author ##{n}" }
  account
end

factory :account do
  sequence(:name) { |n| "Awesome account ##{n}" }
end

现在,试着猜一下,一旦你调用create(:comment),会有多少条记录被创建到数据库中?如果你已经有了答案,请继续往下看。

  • 首先,我们对于comment生成了一个body。但还没有记录被创建,所以这时总数是 0。
  • 接下来,对于comment我们需要一个authorauthor应该属于account,因此我们创建了两条记录。总数:2。
  • 每个 comment 需要一个可注释的对象,对吧?我们这里是answeranswer自身需要一个authoraccount。这就多了三条记录。总数:2 + 2 = 4。【译者注:原文这里有点描述不清晰。前面说“三条记录”,是把 answer、author、account 一起算上的,后面总数计算的数字又按 2 来加,应该没计入 answer。结合后文看,answer 是在最后一步才计入的,所以这里按 2 算是对的。
  • answer也需要一个question,其有自己的author跟后者自身的account。而且,我们的:question factory 也包含一个account关联。总数:4 + 4 = 8
  • 现在我们可以创建answer以及comment本身了。总数: 8 + 2 = 10。

就是如此!使用create(:comment)创建一个 comment 产生了十条数据库记录。

我们需要多个 account 和不同的 author 来测试一个单独的 comment 吗?不太可能吧。

你可以想象出当我们创建多个 comment 时,比如create_list(:comment, 10),会发生什么。休斯敦,我们碰上麻烦了。【译者注:电影《阿波罗13》的经典台词

遭遇 factory cascade——一个通过嵌套 factory 调用生成过量数据的无法控制的过程。

我们可以把 cascade 用一颗树来描绘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
comment
|
|-- author
|    |
|    |-- account
|
|-- answer
     |
     |-- author
     |    |
     |    |-- account
     |
     |-- question
     |    |
     |    |-- author
     |    |    |
     |    |    |-- account
     |    |
     |    |--account

让我们把这种呈现形式称为 factory 树。稍后会用在我们的分析中。

Fire walk with me

EventProf 仅为我们展示了花费在 factories 上的总时间,也因此能够识别出某些东西不对。然而,我们仍然不知道它们在哪儿,除非去挖掘代码做猜谜游戏。使用 TestProf 医疗包中的另一个工具,就不用这么麻烦了。

第二个分析器登场:FactoryProf。你可以这样来运行它:

1
$ FPROF=1 bundle exec rspec

报告结果列出了所有 factories 及其使用情况统计:

1
2
3
4
5
6
7
8
9
10
[TEST PROF INFO] Factories usage

 total      top-level                            name
  1298              2                         account
  1275             69                            city
   524            516                            room
   551            549                            user
   396            117                      membership

524 examples, 0 failures

这里的totaltop-level结果有什么区别?total值是一个 factory 被使用生成数据记录的次数,不管显式使用(通过create调用),还是在另一个 factory 内的隐式使用(通过关联关系和回调);而top-level值仅考虑显式调用。

因此,top-level值和total值之间的明显不同就可能指示了 factory cascade:告知我们一个 factory 更经常从其他 factories 被引用,而非直接调用其自身。

如何准确找出这个“其他 factories”?就用前面讨论过的 factory 树来帮忙!我们来把这颗树平铺展开(使用 pre-order traversal)并把结果列表称为 factory 堆栈

1
2
// factory stack built from the factory tree above
[:comment, :author, :account, :answer, :author, :account, :question, :author, :account, :account]

下面是如何以编程方法构建 factory 堆栈的方式:

  • 每次FactoryBot.create(:thing)被调用时,一个新堆栈就被初始化(使用:smth作为首元素)。
  • 每次另一个 factory 在一个:thing内被使用时,我们把其压入堆栈。

为什么堆栈很棒?正如跟堆栈调用那样,我们可以绘制火焰图!而有什么比火焰图更酷的呢?

FactoryProf 了解如何生成交互的 HTML 火焰图报告,开箱即用。下面是另一个命令行调用:

1
$ FPROF=flamegraph bundle exec rspec

输出结果包含一个 HTML 报告的路径:

1
[TEST PROF INFO] FactoryFlame report generated: tmp/test_prof/factory-flame.html

在你浏览器中打开,可以看到类似这样:

https://cdn.evilmartians.com/front/posts/testprof-2-factory-therapy-for-your-ruby-tests-rspec-minitest/flame1-fe0a939.gif

我们怎样来阅读它?

每一栏代表一个 factory 堆栈。该栏越宽,该堆栈在测试中耗费的时间越多。root那栏展示了 top-level create调用的总数。

如果你的 FactoryFlame 报告看起来象纽约的大厦轮廓线那样参差不齐,这就是有很多 factory cascade 了(每个“摩天大楼”代表一个 cascade):

尽管看起来景色很美,但这并非你理想的无 cascade 报告应有的模样。相反,你的目标应该是象荷兰乡村那样平坦的东西:

Doctor, am I going to live?

知道如何发现 cascade 是不够的——我们还需要消灭它们。让我们来考虑有关的几种技术吧。

Explicit associations

涌上心头的第一个做法就是从我们的 factories 移除所有(或几乎所有)关联关系:

1
2
3
4
5
6
factory :comment do
  sequence(:body) { |n| "Awesome comment ##{n}" }
  # do not declare associations
  # author
  # answer
end

采用这个方案,你在使用一个 factory 时必须明确指定所有需要的关联关系:

1
2
3
4
create(:comment, author: user, answer: answer)

# But!
create(:comment) # => raises ActiveRecord::InvalidRecord

有人可能会问:我们使用 factories 不就是为了每次避免指定所需的参数么?对,没错。用这个方案,factories 变快了,但也更没用了。

Association inference

有时候(通常是在处理 denormalization时)是可能从其他关系推断出关联关系的:

1
2
3
4
5
6
7
8
factory :question do
  sequence(:title) { |n| "Awesome question ##{n}" }
  author
  account do
    # infer account from author
    author&.account
  end
end

现在,我们可以编写create(:question)create(:question, author: user)而不创建一个单独的 account。

我们也能够使用生命周期回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
factory :question do
  sequence(:title) { |n| "Awesome question ##{n}" }

  transient do
    author :undef
    account :undef
  end

  after(:build) do |question, _evaluator|
    # if only author is specified, set account to author's account
    question.account ||= author.account unless author == :undef
    # if only account is specified, set author to account's owner
    question.author ||= account.owner unless account == :undef
  end
end

这个方案会很有效,但需要很多重构(而且,坦率地讲,让 factories 更难阅读)。

Factory default

而 TestProf 提供了另一种方式来消除 cascade——FactoryDefault。它是一个 factory_bot 的扩展,通过允许你隐式重用 factory 内的记录,来启用更简洁和更不易出错的 DSL,以创建有关联关系的默认值。考虑如下示例:

1
2
3
4
5
6
7
8
9
10
11
describe 'PATCH #update' do
  let!(:account) { create_default(:account) }
  let!(:author) { create_deafult(:author) } # implicitly uses account defined above
  let!(:question) { create_default(:question) } # implicitly uses account and author defined above
  let(:answer) { create(:answer) } # implicitly uses question and author defined above

  let(:another_question) { create(:question) } # uses the same account and author
  let(:another_answer) { create(:answer) } # uses the same question and author

  # ...
end

这个方案的主要优势在于你不必修改自己的 factories。你所有需要做的就是把测试中的一些create(…)调用替换为create_default(…)

另一方面,这个特性也为你的测试带来了一些魔法,所以请谨慎使用,因为测试应该尽可能保持可读性。仅针对 top-level 的实体(比如多租户 App 中的租户)使用是个不错的主意。

Bonus: AnyFixture

到目前为止我们只讨论了 factory cascade。从 TestProf 报告中我们还能看出其他什么呢?

让我们再来看一眼 FactoryProf 报告:

1
2
3
4
5
6
7
8
9
[TEST PROF INFO] Factories usage

 total      top-level                            name
  1298              2                         account
  1275             69                            city
   524            516                            room
   551            549                            user

524 examples, 0 failures

注意到roomuser factories 被使用了跟测试用例总数大约相同的次数。因此,在每个用例中两者可能都需要。对于所有用例一次性创建这些记录呢?那么,我们可以使用 fixtures。

由于我们已经有了 factories,重用它们来生成 fixtures 会是很棒的。有请 AnyFixture 登场。

你可以为数据生成使用任何代码块,而 AnyFixture 会在运行结束时负责清理数据库。

AnyFixture 可以很好地跟 RSpec 的 shared contexts 一起工作:

1
2
3
4
5
6
7
8
9
10
11
12
# Activate AnyFixture DSL (fixture) through refinements
using TestProf::AnyFixture::DSL

shared_context "shared user", user: true do
  # You should call AnyFixture outside of transaction to re-use the same
  # data between examples
  before(:all) do
    fixture(:user) { create(:user, room: room) }
  end

  let(:user) { fixture(:user) }
end

然后激活该 shared context:

1
2
3
4
describe CitiesController, :user do
  before { sign_in user }
  # ...
end

随着 AnyFixture 的启用,FactoryProf 报告会看起来这样:

1
2
3
4
5
6
7
total      top-level                            name
 1298              2                         account
 1275             69                            city
  8                1                            room
  2                1                            user

524 examples, 0 failures

看起来完美,不是么?

小孩子才对 factories 和 fixtures 做选择,我们全都要!

附记

感谢阅读!

Factories 为你的测试数据生成带来了简单和灵活,但它们非常脆弱——cascade 如幽灵般出现,而重复地数据创建会消费大量的时间。

照顾好你的 factories 吧,定期带它们去看看医生(TestProf)。让测试更快速,开发者就更快乐!

请阅读 TestProf 介绍文章 以了解更多该项目和其他使用场景背后的初衷。

Rating:

comments powered by Disqus