Ruby Hoedown how conferences get made, and attended
pinned under “Ruby & Rails”

Testing in Ruby, and why flaky is worse than failing

How Ruby projects find out what a compiler would otherwise have told them.

Testing occupies a larger place in Ruby’s culture than in most language communities, for a straightforward reason: the language will happily call a method that does not exist until the moment the line runs, so nothing tells anybody a name was misspelled except running the code. Tests are how Ruby projects find out what a compiler would otherwise have said.

Where a suite’s time goes against what it catches Unit tests are the fastest and catch the narrowest problems. Integration tests cost several times as much per test and catch problems between objects. End-to-end tests that drive a browser are slower again by an order of magnitude and catch the problems users actually meet. A suite weighted towards the slowest kind stops being run. Cost per test against what each kind can catch Unit milliseconds. One object, in isolation. Integration tenths of a second. Objects together. End to end seconds each, a real browser, and the only kind that catches what a user meets Relative cost per test. The shape is the argument: a suite weighted towards the bottom bar becomes a suite nobody runs before pushing. Slow suites stop being run.
What each kind of test costs against what it can find. The bottom bar is the only one that tests the thing a user actually does, which is why the argument never quite settles.

The two frameworks

Minitest ships with Ruby and is small, fast and unremarkable in the way a tool should be. RSpec is a separate gem with its own vocabulary, a richer set of matchers and a structure built around describing behaviour rather than asserting facts.

The choice is mostly cultural and has been argued about for two decades. The practical difference is that RSpec’s expressiveness makes it easy to write tests that read beautifully and assert very little, and Minitest’s plainness makes it hard to hide that a test is thin. Either can be used well.

What matters more than the choice: that the whole project uses one. A codebase with both, arrived at through a half-finished migration, has two test commands, two sets of conventions and a suite nobody can run in a single pass.

What tends to go wrong

Slow suites. Reading the chart, a suite weighted towards the bottom bar stops being run before pushing, and a test suite that is not run before pushing has become a report rather than a check. The fix is proportion, not speed: fewer end-to-end tests, covering the paths that matter, and the rest pushed downwards.

Fixtures and factories. Both approaches drift towards a shared object that every test depends on and nobody can change, which is the point at which a small production change breaks two hundred tests for reasons unconnected to it.

Tests that assert the framework. A test confirming that a validation declared one line above rejects a blank value tests the framework, not the application. It passes forever and protects nothing.

A test that has never failed has never been a test.

Flakiness, and why it is worse than failure

A flaky test fails occasionally for reasons unrelated to the change being tested: timing, ordering, shared state, a clock, a random seed. It is more damaging than a consistently failing test, because a consistent failure gets fixed and an intermittent one teaches everybody to re-run the build.

Once re-running is the normal response to a red build, the suite has stopped being a signal, and every genuine failure after that point is also re-run at least once before anybody looks at it.

The one habit that prevents most of it: randomise test order and keep it randomised. A suite that only passes in one order has hidden dependencies between tests, and finding that out on a quiet afternoon is much better than finding it out during an upgrade.

What is worth testing

The logic somebody would have to think about to write. Calculations, state transitions, anything with a branch in it, and anything that has been wrong before. A bug that reaches production and is fixed without a test around it is a bug with a good chance of returning.

Coverage as a number is a poor target. It measures which lines ran, not whether anything was checked, and a suite can execute every line while asserting almost nothing. As a way of finding whole files nobody has tested it is genuinely useful; as a percentage to hit, it produces tests written to move a number.

Tests as documentation

For anybody trying to work out what a library does, its test suite is frequently a better document than its README, because it is executable and therefore cannot be out of date. That is one of the reasons reading a small gem’s tests is such a good way to learn the language, as learning Ruby sets out.

It also sets a standard for writing them: a test whose name explains what is being checked, and whose body can be read without scrolling, is documentation. One that requires three files of setup context is not, whatever it asserts.

The linters that read the suite alongside the code are on static analysis, the dependencies it pulls in are on gems and Bundler, and the framework that generates most of a suite’s setup is on Rails. The order to meet all of it is on the learning path.

Questions

Why does testing matter more in Ruby than in some other languages?

Because nothing checks a method name until the line runs. A misspelling that a compiler would catch elsewhere is found in Ruby only by executing the code, so tests are doing part of the job a type checker does in other languages.

Minitest or RSpec?

Either, used consistently. Minitest ships with Ruby and is plain; RSpec is expressive enough that a test can read beautifully while asserting very little. What actually matters is that a project uses one of them rather than both.

Why are flaky tests worse than failing ones?

A consistent failure gets fixed. An intermittent one teaches everybody to re-run the build, and once re-running is the normal response to a red build, every genuine failure afterwards is also re-run before anybody looks at it.

Is test coverage a useful target?

Useful for finding whole files nobody has tested, poor as a percentage to hit. It measures which lines ran rather than whether anything was checked, and a suite can execute every line while asserting almost nothing.

What is worth testing?

Anything somebody had to think about: calculations, state transitions, anything with a branch, and anything that has been wrong before. A test that confirms a validation declared one line above still works is testing the framework and protects nothing.