Patterns
A specification apart from any implementation, put to work three ways.
A specification of Fix names no class: it says what a subject does, and the test picks the subject. These three patterns follow from that.
One specification, several implementations
When two classes do the same job, one specification describes the job, and each implementation is tested against it. The specification goes in a file of its own, fix/specs/stack.rb, which fix does not run, since its name does not end with _fix.rb:
require "fix"
Fix :Stack do
on :empty? do
it MUST be_true
end
on :push, 1 do
on :push, 2 do
on :pop do
it MUST eq 2
end
end
end
on :pop do
it MUST be_nil
end
end
Each implementation has a short file that requires it and tests a subject. An Array, in fix/array_fix.rb:
require_relative "specs/stack"
exit(Fix[:Stack].test { [] })
A linked list of the project, in lib/linked_stack.rb:
class LinkedStack
Node = Data.define(:value, :below)
def initialize = @top = nil
def empty? = @top.nil?
def push(value) = tap { @top = Node.new(value, @top) }
def pop
value = @top&.value
@top = @top&.below
value
end
end
and its file, fix/linked_stack_fix.rb:
require_relative "specs/stack"
require_relative "../lib/linked_stack"
exit(Fix[:Stack].test { LinkedStack.new })
fix fix/specs/stack.rb:5: Success: expected true to be true fix/specs/stack.rb:11: Success: expected 2 to eq 2 fix/specs/stack.rb:17: Success: expected nil to be nil fix/specs/stack.rb:5: Success: expected true to be true fix/specs/stack.rb:11: Success: expected 2 to eq 2 fix/specs/stack.rb:17: Success: expected nil to be nil 2 files met
The lines point at the specification, the same three twice; the count names the file of an implementation that is not met. Another implementation is one more short file.
The contract of a dependency
A project relies on what its dependencies do: that JSON.parse gives string keys unless asked otherwise, say. A specification of the dependency writes that down, and tells at the next upgrade whether it still holds:
require "fix"
require "json"
Fix :JSON do
on :generate, { "id" => 1 } do
it MUST eq '{"id":1}'
end
on :parse, '{"id":1}' do
it MUST eq({ "id" => 1 })
end
on :parse, '{"id":1}', symbolize_names: true do
it MUST eq({ id: 1 })
end
end
exit(Fix[:JSON].test { JSON })
ruby json_fix.rb
json_fix.rb:6: Success: expected "{\"id\":1}" to eq "{\"id\":1}"
json_fix.rb:10: Success: expected {"id" => 1} to eq {"id" => 1}
json_fix.rb:14: Success: expected {id: 1} to eq {id: 1}
The subject is the module itself, and each on a call the project makes. Only what the project uses is written, not the whole library.
A contract in three levels
An interface often has methods that are required, others that are recommended, others that are optional: the three levels say so. Here, a plugin MUST transform a text, SHOULD have a name in lowercase, and MAY have a version:
require "fix"
Fix :Plugin do
on :call, "text" do
it MUST be_an_instance_of String
end
on :name do
it SHOULD match(/\A[a-z]+\z/)
end
on :version do
it MAY match(/\A\d+\.\d+\z/)
end
end
class Upcase
def call(text) = text.upcase
def name = "Upcase"
end
class Reverse
def call(text) = text.reverse
def name = "reverse"
def version = "1.2"
end
results = [
Fix[:Plugin].test { Upcase.new },
Fix[:Plugin].test { Reverse.new }
]
exit(results.all?)
ruby plugin_fix.rb plugin_fix.rb:5: Success: expected "TEXT" to be an instance of String plugin_fix.rb:9: Warning: expected "Upcase" to match /\A[a-z]+\z/ plugin_fix.rb:13: Info: NoMethodError: undefined method 'version' for an instance of Upcase plugin_fix.rb:5: Success: expected "txet" to be an instance of String plugin_fix.rb:9: Success: expected "reverse" to match /\A[a-z]+\z/ plugin_fix.rb:13: Success: expected "1.2" to match /\A\d+\.\d+\z/
The first three lines are those of Upcase: its name is not in lowercase, a warning, and it has no version, an info. Both pass, so both plugins meet the contract, and the status is 0. A plugin without call would get an error, and not pass.