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.
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 a built Spinel; then it runs
test/run.sh and melee check. Since the compiler is pinned to a commit and built from source, most of a CI
setup is caching that build.