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

写好RSpec的9个技巧

写好RSpec的9个技巧

看到这篇关于 Rspec 的9个技巧,觉得不错,就简略翻译一下,便于今后查阅。

【以下是正文】

编写好的测试用例跟编写好的代码一样重要。好的 specs 将如同好的文档那样帮助识别 bug。

这儿有 9 个提升 RSpec 的技巧。

有两条原则贯穿这些技巧:

  • DRY
  • 针对正确的目标在正确的地方使用正确的工具

我们开始吧!!!

1、把文件组织放在正确的位置

对于每个测试用例有三个基本的 block:

  • Setup —— before,let,let!
  • Assert —— it
  • Teardown —— after

因此,代码应该放置于对应的合适位置。

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
27
28
29
30
31
# BAD
#
describe '#sync' do
  it 'updates the local account balance' do
    local_account.open
    transfer(1000)
    expect { local_account.sync }.to change { local_account.balance }.by(1000)
    widthdraw_all
    local_account.close
  end
end

# GOOD
#
describe '#sync' do
  subject { local_account.sync }

  before do # Setup
    local_account.open
    transfer(1000)
  end

  after do # Teardown
    widthdraw_all
    local_account.close
  end

  it 'updates the local account balance' do # Assert
    expect { subject }.to change { local_account.balance }.by(1000)
  end
end

2、避免 mock global classes/modules/objects

Global classes/modules/objects 趋向于被用在当前测试空间之外的多个地方。对这些组件的 Mock 将会违背单元测试的隔离原则,这会导致相关的边界效应。

在 Mock class 的new方法时,这个原则更为有效。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# BAD
#
class UserService
  attr_reader :user

  def verify_email
    # ...
    email_service = EmailService.new(user)
    email_service.send_confirmation_email
    # ...
  end
end

describe UserService do
  describe '#verify_email' do
    before do
      email_service = double(:email_service)
      # BAD: Mocking `new` method of EmailService
      allow(EmailService).to receive(:new).and_return(email_service)
      allow(email_service).to receive(:send_confirmation_email)
    end
  end
end


# GOOD
#
class UserService
  attr_reader :user

  def verify_email
    email_service = generate_email_service
    email_service.send_confirmation_email
  end

  private

  # You can also use memoization if ONLY 1 instance of EmailService is needed
  def generate_email_service
    EmailService.new(user)
  end
end

describe UserService do
  describe '#verify_email' do
    before do
      email_service = double(:email_service)
      # GOOD: Mocking its own method `generate_email_service`
      allow(described_class).to receive(:generate_email_service).and_return(email_service)
      allow(email_service).to receive(:send_confirmation_email)
    end
  end
end

3、使用 instance_double 代替 double

当你要创建一个 class 实例的 mock 时,instance_double是更安全的选择。跟double不同,如果被 mock 的行为被实现为所提供 class 的实例方法,则 instance_double会抛出异常。与double相比,这允许我们捕获更深层次的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FootballPlayer
  def shoot
    # ...shoot...
  end
end

messi = instance_double(FootballPlayer)
allow(messi).to receive(:shoot) # OK
allow(messi).to receive(:shoot).with('power') # Wrong numbers of arguments
allow(messi).to receive(:score) # Player does not implement: score

ronaldo = double('FootballPlayer')
allow(ronaldo).to receive(:shoot) # OK
allow(ronaldo).to receive(:shoot).with('power') # OK - but silent failure
allow(ronaldo).to receive(:score) # OK - but silent failure

4、对于测试目标使用 DESCRIBE,对于 scenario 使用 CONTEXT

这只是一种让你的代码读起来更流利的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe UserStore do
  describe '.create' do
    context 'when user does not exists' do
      it 'creates a new user' do
        # ...
      end

      describe 'the newly created user' do
        it 'has the correct attributes' do
          # ...
        end
      end
    end

    context 'when user already exists' do
      it 'raises error' do
        # ...
      end
    end
  end
end

5、DESCRIBE 和 CONTEXT 的实现内容紧挨着其语句下方写

这对于确保测试在所 describe 的 context 下进行 set up 很重要。也帮助了在 context 彼此之间进行区分。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
describe 'FootballPlayer' do
  let(:speed) { 50 }
  let(:shooting) { 50 }

  let(:player) do
    create(:football_player,
      speed: speed,
      shooting: shooting,
    )
  end

  describe '#position' do
    subject { player.position }

    context 'when the player is fast' do
      let(:speed) { 98 } # implements 'when the user is fast'

      it { is_expected.to eq 'winger' }
    end

    context 'when the player shoots well' do
      let(:shooting)  { 90 } # implements 'when the player shoots well'

      it { is_expected.to eq 'striker' }
    end

    context 'when the player is injured' do
      before { player.injure } # implements `when the player is injured`

      it { is_expected.to eq 'benched' }

      context 'when the player uses doping' do # both injured and using doping
        before { player.use_doping }

        it { is_expected.to eq 'midfielder' }
      end
    end
  end
end

6、可能的话,使用 bulk 方法

这是为了告诉阅读者,所有的 assertion 都是针对同一个 subject。

同时,这也是 DRY 的体现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# BAD
it 'has correct attributes' do
  expect(user.name).to eq 'john'
  expect(user.age).to eq 20
  expect(user.email).to eq 'john@ruby.com'
  expect(user.gender).to eq 'male'
  expect(user.country).to eq 'us'
end

# GOOD
it 'has correct attributes' do
  expect(user).to have_attributes(
    name: 'john',
    age: 20,
    email: 'john@ruby.com',
    gender: 'male',
    country: 'us',
  )
end

7、理解在 RSpec 中 transaction 是如何工作的

默认情况下,transaction 由每个 example 进行创建和封装。这允许一个 example 内的所有数据库操作进行回滚以确保下一个 example 处于一个干净的状态。

在诸如before(:context)before(:all)的某些 hook 内创建数据库记录在上述默认 transaction 行为下将不会被回滚。这将导致脏数据及相应的 race conditions。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ontext 'context 1' do
  before(:context) do
    create(:user) # WON'T BE ROLLED-BACK
  end

  before do
    create(:user) # will be rolled-back
  end

  # ...
end

context 'context 2' do
  before(:context) do
    create(:user) # WON'T BE ROLLED-BACK
  end

  # ...
end

# BY NOW, THERE ARE 2 USER RECORDS COMMITED TO DATABASE

8、对于 mock 避免使用 expect

尽管 expect 可被用于 mock 目标,但严格意义上 expect 是用于 assertion 的。

这种情况下,使用 allow 才是正确的。

而且,要提醒我们自己一下,mock 是测试的 setup 场景的一部分,而不是 assertion 场景的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# BAD: expect...and_return
it 'returns the sync value' do
  expect(service).to receive(:sync).and_return(value) # mix between setup and assertion
  expect(subject).to eq value
end

# GOOD
before do
  allow(service).to receive(:sync).and_return(value) # Set up
end

describe 'the service' do
  it 'syncs' do
    expect(service).to receive(:sync) } # assert
  end
end

it { is_expected.to eq value } # assert

9、对于有类似模式的测试用例使用 configs

这是 DRY 的体现,也让阅读者更易于理解。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# BAD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }

  context 'when the filename is empty' do
    let(:filename) { '' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is video123.mp4' do
    let(:filename) { 'video123.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video.edited.mp4' do
    let(:filename) { 'video.edited.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video-edited' do
    let(:filename) { 'video-edited' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is .mp4' do
    let(:filename) { '.mp4' }
    it { is_expected.to eq '' }
  end
end


# GOOD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }

  test_cases = [
    '' => '',
    'video123.mp4' => 'mp4'
    'video.edited.mp4' => 'mp4'
    'video-edited' => ''
    '.mp4' => ''
  ]

  test_cases.each do |test_filename, extension|
    context "when filename = #{test_filename}" do
      let(:filename) { test_filename }
      it { is_expected.to eq extension }
    end
  end
end

comments powered by Disqus