A small Ruby on Rails application, specified and tested with Fix. Users pick a handle, post short texts, follow one another and read a timeline: enough of a microblog to show Fix at work on the three layers of a Rails application, and nothing more.
Ruby 3.3 or later, on a system with fork.
$ bundle install
$ bin/rails db:prepare
$ bundle exec fixbin/rails db:prepare creates the development and the test databases. fix then runs every *_fix.rb file of the fix directory, each in a process of its own, writes one line per expectation, pointing at its line in the specification, and exits with 0 when every file is met. bundle exec rake does the same. The files are executable, so one of them runs on its own: fix/models/follow_fix.rb.
For a look at the application itself, bin/rails db:seed creates three users and a few posts, and bin/rails server serves it at http://localhost:3000. Signing in is picking a handle: there are no passwords in this example.
With Fix, a specification says what a subject MUST, SHOULD or MAY do, and nothing about how the subject is made; a test builds a subject and tests it against the specification. This application keeps the two apart:
spec/holds the specifications, one per file, each aFix :Name do … enddocument. They require Fix and nothing else: no Rails, no database, not even the code they specify.specis the word of Fix here, not of RSpec, which this application does not use.fix/holds the tests, the*_fix.rbfiles thefixcommand runs. Each loads what its subject needs, requires its specification, and ends withexit(Fix[:Name].test { … }), the block building the subject.
The let of a specification is a parameter of the subject: the specification declares it, with a default and the with that vary it, and the block of the test reads it by name. So the same words serve a plain Ruby object, an Active Record model and an HTTP response; only the test changes.
lib/microblog is the domain: two value objects, immutable and deterministic, with no dependency at all. Microblog::Handle says what a handle is, and normalizes the one typed; Microblog::Body says what a post may say, and finds the users it mentions.
# spec/microblog/body_spec.rb, abridged
require "fix"
Fix :Body do
let(:text) { "Hello, @Alice and @bob! cc @bob" }
on :valid? do
it MUST be_true
end
on :mentions do
it MUST eq %w[alice bob]
end
with text: "a" * 281 do
on :valid? do
it MUST be_false
end
end
end# fix/microblog/body_fix.rb
require_relative "../../lib/microblog"
require_relative "../../spec/microblog/body_spec"
exit(Fix[:Body].test { Microblog::Body.new(text) })Neither file loads Rails or a database: the test runs in a tenth of a second. The library is required by config/application.rb as a gem would be, and left out of autoloading, so that its files stay loadable on their own.
app/models holds three Active Record models. User and Post delegate to the domain what belongs to it: a handle is normalized by Microblog::Handle and a body judged by Microblog::Body. What is relational stays in the models, next to the database that enforces it: a handle is unique, a user follows another once and never themselves, and a timeline is a query.
Their tests load the application in its test environment through fix/support/rails.rb. Fix builds the subject anew for every expectation, in the child process of its context, which the other expectations of that context share; Active Record forgets the connections of the parent after a fork, so each child gets a connection of its own. The database is outside the processes, though, so a subject begins by emptying it. Nothing is wrapped in a transaction, and nothing is rolled back.
# spec/models/follow_spec.rb, abridged
Fix :Follow do
let(:handle) { "bob" }
on :valid? do
it MUST be_true
end
with handle: "alice" do
on :errors do
on :full_messages do
it MUST eq ["This user is you"]
end
end
end
end# fix/models/follow_fix.rb
require_relative "../support/rails"
require_relative "../../spec/models/follow_spec"
exit(Fix[:Follow].test do
empty_database
%w[alice bob carol].each { |name| User.create!(handle: name) }
alice = User.find_by!(handle: "alice")
Follow.create!(follower: alice, followed: User.find_by!(handle: "carol"))
Follow.new(follower: alice, followed: User.find_by!(handle:)).tap(&:validate)
end)The subject of a controller test is a response. browser(as: "alice") gives a Rack::Test::Session for the application, signed in; its requests return a Rack::MockResponse, with a status, a body and a location. Rack::Test is a dependency of Action Pack, so nothing else is needed.
# spec/controllers/posts_spec.rb, abridged
Fix :CreatePost do
let(:handle) { "alice" }
let(:body) { "Hello, world!" }
on :status do
it MUST eq 302
end
with body: "a" * 281 do
on :status do
it MUST eq 422
end
end
with handle: nil do
on :location do
it MUST eq "http://example.org/session/new"
end
end
end# fix/controllers/posts_fix.rb
require_relative "../support/rails"
require_relative "../../spec/controllers/posts_spec"
exit(Fix[:CreatePost].test do
empty_database
User.create!(handle: "alice")
browser(as: handle).post("/posts", post: { body: })
end)lib/microblog/ the domain: Handle, Body
app/models/ User, Post, Follow
app/controllers/ Sessions, Timelines, Posts, Users, Follows
spec/microblog/ specifications of the domain
spec/models/ specifications of the models
spec/controllers/ specifications of the controllers
fix/microblog/ tests of the domain, on plain Ruby
fix/models/ tests of the models
fix/controllers/ tests of the controllers
fix/support/rails.rb loads the application for the last two
Passwords, pagination, JavaScript, CSS beyond a few lines in the layout, and any test framework other than Fix. A Rails application that needs Minitest alongside Fix can have it: fix reads its own directory and nothing else.
The code is available as open source under the terms of the MIT License.
This project is sponsored by Sashité.