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 meansmelee devand a browser orcurl. - 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
sqlite3gem 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.