Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing an app

There is no test framework. There is a script that runs your code under CRuby and under the compiler and fails if the two disagree.

No minitest, no RSpec, no gems. What melee’s own example apps do — and what works well — is a plain Ruby file that prints what it observed, a committed file of expected output, and a runner that diffs them and then compiles the same file with Spinel and diffs that against CRuby.

That second diff is the part worth having. It is what catches the dialect traps that make an app behave one way under melee dev and another way deployed.

The shape

test/
  run.sh
  ical_test.rb
  expected/ical.txt
  fixtures/family.ics
# test/ical_test.rb
# frozen_string_literal: true
require_relative "../lib/ical"

cal = ICal.parse(File.read(File.expand_path("fixtures/family.ics", __dir__)))
events = ICal.expand(cal, Time.local(2026, 9, 1), Time.local(2026, 9, 8))

puts "events: #{events.size}"
events.each { |e| puts "#{e.starts_at.strftime("%Y-%m-%d %H:%M")} #{e.summary}" }

No assertions, no framework: it prints. The expected file is the assertion.

#!/usr/bin/env bash
# lib/ical.rb: the expansion must match test/expected/ical.txt, and CRuby and Spinel must agree.
set -euo pipefail
cd "$(dirname "$0")/.."
SPINEL="${SPINEL:-../../vendor/spinel/bin/spinel}"
OUT=$(mktemp -d)
export TZ=Europe/London

ruby test/ical_test.rb > "$OUT/cruby.out"
diff test/expected/ical.txt "$OUT/cruby.out"

"$SPINEL" test/ical_test.rb -o "$OUT/bin" && "$OUT/bin" > "$OUT/spinel.out"
diff "$OUT/cruby.out" "$OUT/spinel.out" && echo IDENTICAL

export TZ matters: anything touching Time.local is machine-dependent otherwise.

Add a --bless flag that copies the new output over the expected file, and make reading git diff test/expected/ part of accepting a change. The diff is the review.

What to test this way

Pure functions. Parsing, formatting, expansion, calculation — anything in lib/ that takes values and returns values. This is the strongest argument for keeping logic in modules rather than inside route blocks: a route block cannot be tested this way, and a module function can.

Anything with a dialect risk. Time arithmetic, string building, regular expressions, JSON round trips, anything using Struct or pattern matching. These are exactly where CRuby and the compiler can differ, and the parity diff is the only thing that will tell you.

What you cannot test this way

  • Routes. There is no test client, no get "/" helper, no way to drive the router in-process. Checking routes means melee dev and a browser or curl.
  • Templates. They are compiled by the build step; a test file cannot render one.
  • Durable objects. They need a worker, or the dev server’s in-process stand-in.
  • The database, unless you open one yourself, which means the CRuby half needs the sqlite3 gem and the Spinel half does not use the same backend.

That is a real gap, and it means the practical test strategy for a melee app is: pure logic under the parity runner, everything else by clicking through melee dev and reading the log.

Routes, objects and timers: melee test

The shape above is for the pure part of an app, a library with inputs and outputs. For the part that needs a request, a durable object or a timer there is test/app_test.rb, which melee new starts for you and melee test runs:

setup { post "/games", system: "5-1" }          # before each test; its response is the starting point

test "a new game answers at its own URL" do
  assert_equal 303, status
  get "/g/#{redirect_token}"
  assert_equal 200, status
  assert body.include?("rotation 1 of 6")
end

test "a game deletes itself after a week idle" do
  token = redirect_token
  object(Game, token).touch_for_test(8 * 24 * 3600)   # reach into the object, no HTTP
  fire_timer Game, token                              # run its timer now
  get "/g/#{token}"
  assert_equal 404, status
end

Each test starts with a fresh browser session; the app and its database are shared across the file, so a test must not depend on what another one left behind. get, post, put, patch and delete go through the routes as a browser would, a POST carries the CSRF token, and status, body, header(name) and redirect_token read the last response. object(Klass, id) is the live object, storage(Klass, id) its database, and fire_timer runs the timer without waiting for it.

melee test              # under CRuby, in one process: fast, what you run while writing
melee test --spinel     # compile first, then the same file against the binary, over the wire
melee test --both       # both, and a diff of the two transcripts

--spinel runs the app the way the server runs it — a compiled binary, a worker process, frames over a socket — so it is the one that catches a dialect trap in a route or an object. object, storage and fire_timer reach into the app’s own process and are skipped under --spinel; --both compares only what ran under both.

That comparison is a digest of each response with object ids and the CSRF token masked, since those differ between any two runs. If a page carries a value of your own that does too — a key generated per database in a <meta> tag, say — declare a pattern for where it appears at the top of the file and it is masked as well:

mask(/name="vapid-public-key" content="\K[^"]+/)

A pattern rather than the value itself, because under --spinel the binary has a database of its own, so the value the test file could read would be the CRuby side’s and not the one on the binary’s pages.

The other three checks

ruby -wc app.rb lib/*.rb     # syntax and warnings, instantly
melee check                  # the full compile: templates, migrations, dialect, the build
melee dev                    # click through it

melee check is not a test. It proves the program compiles, which as the tutorial explains does not prove a misspelled method is absent. Run all three.

In CI

There is nothing melee-specific. A CI job needs Ruby, the sqlite3 gem and melee (a C compiler, git, make and curl let it build its own compiler); then it runs test/run.sh, melee test --both and melee check. The compiler goes into ~/.melee/spinel/<pin> on the first compile, so most of a CI setup is caching that directory.

See also