In a project
Where specifications live, how they load the code, and what to know when they run.
This page takes a small project: a product and a cart, in lib, and their specifications, in fix.
Layout
The code lives where it already does, lib/product.rb and lib/cart.rb:
Product = Data.define(:name, :price)
require_relative "product"
class Cart
def initialize(*products)
@products = products.freeze
end
def count = @products.size
def total = @products.sum(&:price)
def add(product) = Cart.new(*@products, product)
end
Each specification has a file of its own in fix, named after what it specifies, fix/product_fix.rb and fix/cart_fix.rb, with subdirectories as the project grows. A file requires Fix and the code it tests, writes its specification, and tests a subject:
require "fix"
require_relative "../lib/product"
Fix :Product do
on :name do
it MUST be_an_instance_of String
end
on :price do
it MUST be_a_kind_of Numeric
end
end
exit(Fix[:Product].test { Product.new(name: "Tea", price: 4) })
require "fix"
require_relative "../lib/cart"
TEA = Product.new(name: "Tea", price: 4)
CAKE = Product.new(name: "Cake", price: 6)
Fix :Cart do
let(:products) { [] }
on :count do
it MUST eq 0
end
with products: [TEA, CAKE] do
on :total do
it MUST eq 10
end
on :add, TEA do
on :count do
it MUST eq 3
end
end
end
end
exit(Fix[:Cart].test { Cart.new(*products) })
fix fix/cart_fix.rb:11: Success: expected 0 to eq 0 fix/cart_fix.rb:16: Success: expected 10 to eq 10 fix/cart_fix.rb:21: Success: expected 3 to eq 3 fix/product_fix.rb:6: Success: expected "Tea" to be an instance of String fix/product_fix.rb:10: Success: expected 4 to be a kind of Numeric 2 files met
The products are constants of the file: a with takes values that exist when the file is loaded, while a let builds its value for each subject.
Loading the code
Above, each file loads the code with require_relative, which works from any directory and with plain ruby. Two other ways are common:
RUBYOPT=-Ilib fixputslibon the load path of every file, which then writesrequire "cart".- In a gem whose Gemfile says
gemspec,bundle exec fixputs the gem on the load path, and the filesrequireit by name, as its users do.
A file shared by several specification files, a helper or a specification that others require, goes in fix under a name that does not end with _fix.rb, so that fix does not run it on its own: Patterns has one.
Next to other tests
Fix needs nothing from another test framework, and changes nothing in it: minitest in test, RSpec in spec and Fix in fix run side by side, each with its command. A Rakefile can run them all, task default: %i[test spec fix] with a task fix that runs sh "fix". A specification of Fix can also be checked from a test of another framework, as a matcher, with matchi-fix.
What a process does not isolate
Each context runs in a child process: what a subject changes in its process, a global, a constant, the current directory, ENV, ends with the child. What it changes outside its process stays: a file it writes, a row it inserts, a message it sends.
- Give each subject what it writes to: a directory of its own, a database it creates, an object that stands for a service. The subject block is the place, since it runs for each expectation.
- A child starts as a copy of its parent, open files and connections included, and shares them with it. Open a connection in the subject block, not in the file around it.
- Run
fix --randomfrom time to time: a file met in one order and not in another depends on what another one left behind.
Debugging
To stop in a subject with a debugger, run its contexts in the process itself: Fix[:Cart].test(fork: false) { … }. Each context then sees what the ones before it left, so use it only while debugging.
Coverage tools such as SimpleCov store what they measure when a process ends normally, and a child of Fix ends with exit!, which skips that: what runs in the children is not counted, unless the tool stores its result before. The README of fix details the processes.