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.

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.

See also