写好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