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.