Skip to content

Commit 2baaa6f

Browse files
committed
Publish post "Flaky Specs"
1 parent f736466 commit 2baaa6f

File tree

1 file changed

+161
-0
lines changed

1 file changed

+161
-0
lines changed

_posts/2021-09-29-flaky-specs.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
title: Flaky Specs
3+
date: 2021-09-29 13:00:00 +0200
4+
categories: ruby specs testing
5+
---
6+
7+
Once an application starts to get complex, the test suite will also start to grow, and you will most likely experience more and more flaky specs. In this post I'll explore an example of this from the test suite of Elastic Enterprise Search.
8+
9+
## Flaky?
10+
11+
If a spec/test fails in certain situations, at seemingly random times, it could be considered flaky. Ideally, you should try to fix this the first time you see it but sometimes it doesn't happen enough to warrant spending a lot of time on it. It's also not always easy to reproduce the issue on your local machine. The flaky spec blocking your PR could also be happening in a part of the application that you're not responsible for.
12+
13+
There are a lot of reasons not to worry about flaky specs until it starts to become an issue that affects a lot of people. Here I'll highlight one recent example and how that specific failure could've been avoided by thinking a bit broadly about the test suite and also by perhaps writing the non-test code a bit differently.
14+
15+
## Scenario
16+
17+
Here's a simplified version of the code and test for which we were experiencing specs that started to fail more frequently on CI but running the spec in isolation was working fine:
18+
19+
```ruby
20+
require 'bundler/inline'
21+
22+
gemfile do
23+
source 'https://rubygems.org'
24+
gem 'rspec'
25+
end
26+
27+
require 'yaml'
28+
29+
module EnterpriseSearch
30+
def self.product_version
31+
@product_version ||= Gem::Version.new(File.read('./product_version').chomp)
32+
end
33+
34+
class ConfigParser
35+
attr_reader :parsed_config
36+
37+
def initialize(file_name)
38+
@file_name = file_name
39+
@parsed_config = parse_config_file!
40+
end
41+
42+
private
43+
44+
attr_reader :file_name
45+
46+
def parse_config_file!
47+
config = YAML.safe_load(File.read(file_name))
48+
49+
if EnterpriseSearch.product_version >= Gem::Version.new('8.0')
50+
if config.key?('monitoring')
51+
raise "monitoring config is removed from 8.0 (your version: #{EnterpriseSearch.product_version})"
52+
end
53+
end
54+
55+
config
56+
end
57+
end
58+
end
59+
60+
require 'rspec/autorun'
61+
62+
RSpec.configure do |rspec|
63+
rspec.register_ordering(:global) do |items|
64+
if ENV['SPEC_ORDER'] == 'reverse'
65+
items.reverse
66+
else
67+
items
68+
end
69+
end
70+
end
71+
72+
RSpec.describe EnterpriseSearch do
73+
describe '.product_version' do
74+
it 'should return version' do
75+
expect(EnterpriseSearch.product_version).to be_a(Gem::Version)
76+
end
77+
end
78+
end
79+
80+
RSpec.describe EnterpriseSearch::ConfigParser do
81+
describe '#parsed_config' do
82+
it 'should parse' do
83+
expect(File).to receive(:read).and_return('hello: world')
84+
config_parser = described_class.new('banana.yml')
85+
expect(config_parser.parsed_config).to eq({ 'hello' => 'world' })
86+
end
87+
end
88+
end
89+
```
90+
91+
Running this file normally, the specs will not fail. But if you run it with a special environment variable, you will see it fail:
92+
93+
```bash
94+
$ SPEC_ORDER=reverse ruby flaky_spec.rb
95+
F.
96+
97+
Failures:
98+
99+
1) EnterpriseSearch::ConfigParser#parsed_config should parse
100+
Failure/Error: @product_version ||= Gem::Version.new(File.read('./product_version').chomp)
101+
102+
ArgumentError:
103+
Malformed version number string hello: world
104+
# flaky_spec.rb:12:in `product_version'
105+
# flaky_spec.rb:30:in `parse_config_file!'
106+
# flaky_spec.rb:20:in `initialize'
107+
# flaky_spec.rb:65:in `new'
108+
# flaky_spec.rb:65:in `block (3 levels) in <main>'
109+
110+
Finished in 0.01049 seconds (files took 0.09006 seconds to load)
111+
2 examples, 1 failure
112+
113+
Failed examples:
114+
115+
rspec flaky_spec.rb:63 # EnterpriseSearch::ConfigParser#parsed_config should parse
116+
```
117+
118+
The difference is in the **order** of the specs. Order-dependent specs are not something you want and luckily in this case the solution is mostly straightforward.
119+
120+
## Solution
121+
122+
Stubbing `File.read` with
123+
124+
```ruby
125+
expect(File).to receive(:read).and_return('hello: world')
126+
```
127+
128+
means it will also be stubbed for the `File.read` in `EnterpriseSearch.product_version`. As an added complexity, the product version is memomized/cached which means that in some cases it will try to read the product version from a file and sometimes it will use the memomized value.
129+
130+
If we change the above stub to
131+
132+
```ruby
133+
expect(File).to receive(:read).with('banana.yml').and_return('hello: world')
134+
```
135+
136+
we will still get an error:
137+
138+
```
139+
Failure/Error: @product_version ||= Gem::Version.new(File.read('./product_version').chomp)
140+
141+
#<File (class)> received :read with unexpected arguments
142+
expected: ("banana.yml")
143+
got: ("./product_version")
144+
```
145+
146+
This is not a big deal as we can do
147+
148+
```ruby
149+
expect(File).to receive(:read).with('banana.yml').and_return('hello: world')
150+
expect(File).to receive(:read).and_call_original
151+
```
152+
153+
so other `File.read`s are called as normal.
154+
155+
## Retrospective
156+
157+
In retrospect, it seems much better to actually read a file from disk in the spec. With Ruby's `TempFile` we can create temporary files that should be cleaned up automatically. That way we can avoid the stub altogether.
158+
159+
Alternatively the `ConfigParser` class could also accept any IO object and in tests we could pass in `StringIO` so we avoid writing to and reading from disk.
160+
161+
Memoization, like we saw in `EnterpriseSearch.product_version`, also introduces pitfalls. In some scenarios you could do `EnterpriseSearch.instance_variable_set(:@product_version, nil)` to clear the cached value and thereby expose that there is actually a `File.read` hidden underneath. But having to think about that kind of stuff in unrelated specs is not easy to deal with so it would be preferable to having stubs/mocks that are local to the specs or let the related specs deal with resetting data so unrelated specs are unaffected.

0 commit comments

Comments
 (0)