always use Capybara matchers first
There’s a deep well of blog posts out there about using Capybara. I wanted to add one recent observation I had that I hadn’t seen.
Capybara made the choice to abstract away from you its concurrency control. For the most part, this is nice. But I think every person that’s ever used this library has dealt with intermittent failures. The background on that topic in general is handled better elsewhere.
The issue I want to highlight comes with mixing testing APIs without knowing the work Capybara is doing for you.
Here’s an example from RSpec:
1
2
expect(Model.last.name).to eq('model name')
expect(page).to have_text('model name')
Probably looks fine on first blush. But over enough runs you’ll likely see
intermittent failures. Why? Because while the Capybara matcher have_text
is handling concurrency correctly, the RSpec matcher eq
is not! In this
example, the model might not be created yet, but will be once have_text
has finished waiting for it.
Simply resequencing these two lines will save you the failures. Better yet, you should probably not be using anything but Capybara matchers in your feature tests.
To that end, I forked
rubocop-rspec and created an
example Cop
to catch sequencing errors like this in static analysis:
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
require 'capybara/rspec/matchers'
require 'rspec/matchers'
module RuboCop
module Cop
module RSpec
# When using acceptance testing with Capybara, using
# non-Capybara matchers before Capybara matchers will
# introduce intermittent failures and should be avoided.
#
# @example
# # bad
# it '...' do
# expect(Model.last.name).to eq('model name')
# expect(page).to have_text('model name')
# end
#
# # better
# it '...' do
# expect(page).to have_text('model name')
# expect(Model.last.name).to eq('model name')
# end
#
# # best
# it '...' do
# expect(page).to have_text('model name')
# end
class CapybaraOrdering < Cop
include RuboCop::RSpec::SpecOnly,
RuboCop::RSpec::Language
MSG = 'To maintain Capybara concurrency protection,' \
' swap the line sequence of `%{one}` and `%{two}`.'.freeze
# capybara and rspec intersect on some methods, leading to
# ambiguous cases. right now we're just handling `within` but
# moving to an explicit whitelist or a configurable one might
# be necessary to really parse a full codebase
SAFE_MATCHERS = Capybara::RSpecMatchers.instance_methods(false).
push(:within).freeze
UNSAFE_MATCHERS = ::RSpec::Matchers.instance_methods(false).
tap {|h| h.delete(:expect) }.tap {|h| h.delete(:within) }.freeze
ALL_MATCHERS = SAFE_MATCHERS + UNSAFE_MATCHERS
def_node_matcher :example?, <<-PATTERN
(block (send _ {#{Examples::ALL.to_node_pattern}} ...) ...)
PATTERN
def_node_search :matcher, <<-PATTERN
(send _ {
#{ALL_MATCHERS.to_s.delete('[]').gsub(',',"\n")}
}
...)
PATTERN
def on_block(node)
return unless example?(node) && (matchers = matcher(node))
return if matchers.to_a.size <= 1
one = matchers.next
two = matchers.next
if UNSAFE_MATCHERS.include?(one.method_name) && SAFE_MATCHERS.include?(two.method_name)
add_offense(one, :expression, MSG % { one: one.method_name, two: two.method_name} )
end
end
def autocorrect(node)
matchers = matcher(node.parent.parent)
one = matchers.next
two = matchers.next
lambda do |corrector|
safe = two.parent
unsafe = one.parent
corrector.replace(unsafe.loc.expression, safe.source)
corrector.replace(safe.loc.expression, unsafe.source)
end
end
end
end
end
end
This includes an #autocorrect
method so you can run rubocop -a
to fix these problems. You can see
this
and its
spec
in their original context on
github. I’ve only used this Cop
on one system, and it is imperfect as written. But I think it could be a fruitful direction, so feel free to hack on it.