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