Fixrb

Fix guide, version 1

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.

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:

LevelIt doesIt does notNo such methodAnother exception
MUSTSuccessFailureErrorError
SHOULDSuccessWarningErrorError
MAYSuccessFailureInfoError

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.