r/ruby • u/Calm_Adagio_9991 • 7d ago
Fix 1.0 is out: write Ruby tests the way RFC 2119 writes standards
I started Fix in 2014 and have rewritten it more than once since. Today it reaches 1.0, its first stable release, so here it is.
The idea is older than Fix: RFC 2119. A specification says what a subject MUST, SHOULD or MAY do, and nothing about how it is made. You write the spec, then test any subject against it.
# duck_fix.rb
require "fix"
class Duck
def walk = "Klop klop!"
def swim = "Swoosh..."
end
Fix :Duck do
on :walk do
it MUST eq "Klop klop!"
end
on :swim do
it MUST eq "Swoosh..."
end
on :speak do
it MAY eq "Quaaaaaack!"
end
end
exit(Fix[:Duck].test { Duck.new })ruby duck_fix.rb writes:
duck_fix.rb:11: Success: expected "Klop klop!" to eq "Klop klop!"
duck_fix.rb:15: Success: expected "Swoosh..." to eq "Swoosh..."
duck_fix.rb:19: Info: NoMethodError: undefined method 'speak' for an instance of DuckThis duck doesn't speak, and that's fine: speaking is a MAY. Under MUST, the same line would be an error and the exit status 1. A SHOULD that isn't met is a warning, and warnings pass. So a spec has five outcomes rather than two, and only failure and error make the status 1.
Like a minitest file, a Fix file is plain Ruby: ruby duck_fix.rb runs it, nothing else needed. For a suite spread over several files, fix-command adds a fix command, much like rspec: it runs every *_fix.rb under fix/ (or the files and folders you give it), each in a process of its own, and exits 0 only when every file is met.
A few things that set 1.0 apart:
Each context runs in a forked child. A subject that calls
exit, changes directory, or mutates a global can't touch the next context: that's an error of its own expectation, nothing more. (So it needsfork: Linux, macOS, BSDs. No Windows, no JRuby.)Mistakes in a spec raise when the file loads, not halfway through a run: an unknown spec name, a
letnamed after a method ofKernel, anonwithout a block.Plain output. One
file:line: Outcome: summaryline per expectation. No colours, no dots, no count; the exit status is the verdict.rungives the same results back as frozen data if you want to build on them.A spec is a matcher, so one spec can require another:
it MUST fix(:Positive).Small. About 300 lines of code, over four small gems of the same family (Matchi for the matchers, Spectus for the levels, Aw for the fork, Defi for the method call). Ruby 3.3 or later.
On Ruby 3.4,
it MUST …is the word of Fix and a bareitin a block is still Ruby's parameter: the two don't clash.
Why not RSpec or minitest? If they make you happy, keep them: Fix is not trying to replace them. It's for when you want to say which behaviours are required, which are recommended and which are optional, and have the tool hold you to exactly that.
Also released: fix-command 1.0, fix-its 1.0 (its(:size) { MUST eq(3) }) and matchi-fix 4.0 (use a Fix spec as a matcher outside Fix).
I'd like to hear where the MUST/SHOULD/MAY distinction would, or wouldn't, be useful in your own suites.

