Writing specifications
Four words, on, let, with and it, and three levels of requirement.
The block of Fix is a document. It is read once, when the file is loaded, and builds the specification before any subject exists: no setup, no teardown, no state. Something wrong in it, such as an it without a requirement or a let declared twice in one context, raises then, before any test.
Methods: on
on opens a context that sends a method to the subject, with its arguments; the expectations inside it judge what comes back. An on inside another sends its method to that value, and so on. An it outside any on judges the subject itself.
require "fix"
Fix :Phrase do
it MUST be_an_instance_of String
on :upcase do
it MUST eq "HELLO WORLD"
on :length do
it MUST eq 11
end
end
on :split, " " do
it MUST eq %w[hello world]
end
on :start_with?, "hello" do
it MUST be_true
end
end
exit(Fix[:Phrase].test { "hello world" })
ruby phrase_fix.rb phrase_fix.rb:4: Success: expected "hello world" to be an instance of String phrase_fix.rb:7: Success: expected "HELLO WORLD" to eq "HELLO WORLD" phrase_fix.rb:10: Success: expected 11 to eq 11 phrase_fix.rb:15: Success: expected ["hello", "world"] to eq ["hello", "world"] phrase_fix.rb:19: Success: expected true to be true
A question is a method too: on :start_with?, "hello" asks it, and be_true wants the answer true. A method that raises ends the chain there: its expectations are errors, unless their matcher is raise_exception, which is made for that. The method is sent without a block, since the block of on is the context.
Parameters: let and with
A let is a parameter of the subject: a name and a block that gives its value. The block of test calls it to build the subject. with opens a context where parameters take other values:
require "fix"
class Price
def initialize(amount, rate: 0)
@amount = amount
@rate = rate
end
def total = @amount - (@amount * @rate / 100)
end
Fix :Price do
let(:amount) { 100 }
let(:rate) { 0 }
on :total do
it MUST eq 100
with rate: 20 do
it MUST eq 80
end
end
with amount: 50, rate: 10 do
on :total do
it MUST eq 45
end
end
end
exit(Fix[:Price].test { Price.new(amount, rate:) })
ruby price_fix.rb price_fix.rb:17: Success: expected 100 to eq 100 price_fix.rb:20: Success: expected 80 to eq 80 price_fix.rb:26: Success: expected 45 to eq 45
A context inherits the parameters and the methods of the contexts around it: inside on :total, with rate: 20 builds the price at 20% and sends it total. A with may come before an on or inside it.
- A let is visible from the subject block and from the other lets, not from the expectations, which are written before any subject exists: naming it there raises
NameErrorat load. - Its value is computed the first time it is called, and kept for the rest of the expectation. Each expectation builds its subject anew:
let(:items) { [] }gives each one its own array, whilewith items: []gives the same array to the expectations of its context. - A let cannot take the name of a method every object has, public or private, such as
format, nor of a word of Fix, such aseq:let(:format)raisesArgumentErrorat load,format is already a method of Kernel, so that nothing is redefined in silence.
Expectations: it
it takes a requirement: a level, then a matcher, as in it MUST eq 100. The levels are the words of RFC 2119: MUST, SHOULD and MAY, with MUST_NOT and SHOULD_NOT for a negation; RFC 2119 has no negative MAY. The matchers have a page of their own.
require "fix"
Player = Data.define(:name, :score)
Fix :Player do
on :name do
it MUST eq "Ada"
end
on :score do
it MUST_NOT be_nil
it SHOULD eq 100
end
on :avatar do
it MAY match(/\.png\z/)
end
end
exit(Fix[:Player].test { Player.new(name: "Ada", score: 90) })
ruby player_fix.rb player_fix.rb:7: Success: expected "Ada" to eq "Ada" player_fix.rb:11: Success: expected 90 not to be nil player_fix.rb:12: Warning: expected 90 to eq 100 player_fix.rb:16: Info: NoMethodError: undefined method 'avatar' for an instance of Player
Two questions tell the levels apart. Must the method exist? MUST and SHOULD say yes, MAY says no. Must it do what is written? MUST and MAY say yes, SHOULD only recommends it. Hence five outcomes:
| Level | It does | It does not | No such method | Another exception |
|---|---|---|---|---|
MUST | Success | Failure | Error | Error |
SHOULD | Success | Warning | Error | Error |
MAY | Success | Failure | Info | Error |
A success, a warning and an info pass: the player above meets its specification, and the status of the process is 0. A failure and an error do not. A negation swaps the first two columns: MUST_NOT be_nil succeeds when the score is not nil.
Below an error come the frames of its backtrace that are not in Fix and its gems, indented by two spaces, so that the line of the code that raised is there: the matchers page shows one, under raise_exception.
One process per context
Each context with expectations runs in a child process of the one that tests, the expectations of a context in the same child, and the contexts one after the other, a context before those inside it. What a subject changes in its process, a global, a constant, the current directory, ENV, stays in its context, and the order of the contexts cannot change a result. What it changes outside the process, a file or a database, does not stay there: In a project comes back to it.