If you have built applications with ruby.wasm, you might have encountered a frustrating developer experience when writing unit tests.
Testing Ruby logic compiled to WebAssembly usually meant embedding Ruby code inside JavaScript template literals and asserting the results using Node.js test runners.
I wanted to write clean, idiomatic RSpec tests for my Ruby WebAssembly code, so I built rspec-wasm—a test runner that executes native RSpec directly within a Node.js-based ruby.wasm VM.
The Old Way: Writing Ruby Tests in JavaScript
When testing ruby.wasm modules, you typically had to pass stringified Ruby code into vm.eval() and serialize the output to JSON so JavaScript could inspect it:
import { test } from "node:test";
import assert from "node:assert";
test("starts with an empty board and player X", async () => {
// Writing Ruby code inside JS template literals...
const resultJson = await vm.eval(`
require_relative "../lib/tic_tac_toe"
game = TicTacToe.new
# Needs explicit JSON serialization to pass values to JS
{ current_player: game.current_player, winner: game.winner }.to_json
`);
const data = JSON.parse(resultJson.toString());
// Relying on JS assertions instead of RSpec syntax...
assert.strictEqual(data.current_player, "X");
assert.strictEqual(data.winner, null);
});
This approach comes with noticeable drawbacks:
- No syntax highlighting or autocomplete in your editor for the embedded Ruby code.
- Manual serialization (
.to_json) just to exchange data between VM boundaries. - Loss of RSpec's intuitive matcher syntax and failure reporting.
The New Way: Pure RSpec with rspec-wasm
rspec-wasm eliminates the JavaScript wrapper layer. You write standard Ruby files and run them with a single CLI command.
-
Standard File Discovery: Auto-detects
spec/**/*_spec.rbfiles. -
Full IDE Support: Enjoy syntax highlighting, formatting, and linting in standard
.rbfiles. -
Zero Host Setup: Runs instantly via
npxwithout requiring a local Ruby environment.
Tutorial: Testing a Tic-Tac-Toe Game
Let's walk through building a game logic module and testing it with rspec-wasm.
1. Setup
Initialize your project and install rspec-wasm:
mkdir tictactoe-wasm
cd tictactoe-wasm
npm init -y
npm install --save-dev rspec-wasm
2. Write the Game Logic (lib/tic_tac_toe.rb)
Create a pure Ruby class that handles game state without DOM or Web dependencies:
class TicTacToe
attr_reader :board, :current_player, :winner
def initialize
@board = Array.new(9, nil)
@current_player = "X"
@winner = nil
end
def move(index)
return false if index < 0 || index > 8 || @board[index] || @winner
@board[index] = @current_player
if check_winner
@winner = @current_player
else
@current_player = (@current_player == "X" ? "O" : "X")
end
true
end
def draw?
@board.none?(&:nil?) && @winner.nil?
end
private
def check_winner
lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
]
lines.any? { |a, b, c| @board[a] && @board[a] == @board[b] && @board[a] == @board[c] }
end
end
3. Write RSpec Tests (spec/tic_tac_toe_spec.rb)
Write standard RSpec specs requiring your library:
require "tic_tac_toe"
RSpec.describe TicTacToe do
subject(:game) { TicTacToe.new }
it "starts with an empty board and player X" do
expect(game.current_player).to eq("X")
expect(game.winner).to be_nil
end
it "alternates turns between players" do
game.move(0) # X
expect(game.current_player).to eq("O")
game.move(1) # O
expect(game.current_player).to eq("X")
end
it "detects a winning row" do
game.move(0) # X
game.move(3) # O
game.move(1) # X
game.move(4) # O
game.move(2) # X wins
expect(game.winner).to eq("X")
end
end
4. Run the Tests
Execute the CLI command in your terminal:
npx rspec-wasm
Output:
TicTacToe
starts with an empty board and player X
alternates turns between players
detects a winning row
Finished in 0.06 seconds (files took 1.7 seconds to load)
3 examples, 0 failures
Bonus: Running the Tested Logic in the Browser
Because the core domain logic in lib/tic_tac_toe.rb is decoupled from the UI, you can import it directly into your frontend HTML using ruby.wasm:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ruby.wasm Tic-Tac-Toe</title>
<script src="https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2.5.0/dist/browser.script.iife.js"></script>
<style>
.board { display: grid; grid-template-columns: repeat(3, 80px); gap: 5px; }
.cell { width: 80px; height: 80px; font-size: 32px; font-weight: bold; cursor: pointer; }
</style>
</head>
<body>
<h1>ruby.wasm Tic-Tac-Toe</h1>
<div id="status">Loading...</div>
<div class="board" id="board"></div>
<script type="text/ruby" data-eval="async">
require "js"
# Fetch and evaluate the tested Ruby logic
response = JS.global.fetch("lib/tic_tac_toe.rb").await
eval(response.text.await.to_s)
$game = TicTacToe.new
def render
document = JS.global[:document]
status_text = $game.winner ? "Winner: #{$game.winner} 🎉" : "Turn: #{$game.current_player}"
document.getElementById("status")[:innerText] = status_text
board_el = document.getElementById("board")
board_el[:innerHTML] = ""
$game.board.each_with_index do |cell, idx|
btn = document.createElement("button")
btn[:className] = "cell"
btn[:innerText] = cell || ""
btn.addEventListener("click") { $game.move(idx) && render }
board_el.appendChild(btn)
end
end
render
</script>
</body>
</html>
Serve the directory (npx serve .) and open it in your browser to play the game with verified logic!
Conclusion
By separating pure Ruby business logic from DOM integration, you can maintain test coverage using native RSpec while deploying to WebAssembly environments.
- npm: rspec-wasm
- GitHub: dogrun-inc/rspec-wasm
Top comments (0)