DEV Community

constdrop
constdrop

Posted on

Stop Writing Ruby Tests in JS Template Literals: Meet rspec-wasm

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);
});

Enter fullscreen mode Exit fullscreen mode

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.rb files.
  • Full IDE Support: Enjoy syntax highlighting, formatting, and linting in standard .rb files.
  • Zero Host Setup: Runs instantly via npx without 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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

4. Run the Tests

Execute the CLI command in your terminal:

npx rspec-wasm

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)