DEV Community

constdrop
constdrop

Posted on

rspec-wasm v0.2.0: Testing Ruby Code That Integrates with JavaScript and WebAssembly

In September 2026, I released rspec-wasm, a tool for running RSpec tests on ruby.wasm, and introduced it in my previous DEV article:

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

I've now released rspec-wasm v0.2.0, which adds support for asynchronous JavaScript setup and teardown around RSpec execution.

One of the great advantages of building applications with WebAssembly is the ability to combine functionality from different Wasm modules.

However, until now, rspec-wasm had no way to asynchronously initialize an external runtime before running RSpec tests or clean it up afterward.

With v0.2.0, we can initialize external JavaScript or Wasm runtimes before running RSpec, making it possible to test Ruby code that interacts with them.

In this article, I'll demonstrate the new functionality by testing Ruby code that uses a WebAssembly-based MP3 decoder.

Why Use External Wasm Modules with ruby.wasm?

When developing applications with ruby.wasm, building a custom Wasm binary can expand what's possible.

However, if we want to use existing gems with a prebuilt ruby.wasm runtime, we're currently limited to pure Ruby gems.

This can lead to a few challenges:

  • An appropriate pure Ruby gem might not exist for the functionality we need.
  • A pure Ruby implementation might not provide the performance we expect.
  • Our application might be designed to combine multiple Wasm modules from the beginning.

In these situations, using external Wasm modules alongside ruby.wasm can be a practical solution.

For example, we might want to integrate:

  • Image and video processing libraries
  • Databases such as SQLite
  • Python/Wasm runtimes
  • Compression and cryptography libraries
  • Numerical computing or machine learning runtimes

But how can we test Ruby code that depends on these external runtimes?

That's where the new functionality in rspec-wasm v0.2.0 comes in.

Example: Decoding MP3 Audio with WebAssembly

Let's build a small example that initializes a WebAssembly-based MP3 decoder, calls it from Ruby, and verifies the decoded audio information using RSpec.

We'll use the following:

  • Node.js 18 or later
  • rspec-wasm 0.2.0
  • mpg123-decoder 1.0.3

Both ruby.wasm and RSpec are included in rspec-wasm, so we don't need to install them separately.

mpg123-decoder is an MP3 decoder that runs mpg123 through WebAssembly.

1. Create the Project

First, create a new project and install the dependencies.

mkdir rspec-wasm-mp3-example
cd rspec-wasm-mp3-example
npm init -y
npm install mpg123-decoder@1.0.3
npm install --save-dev rspec-wasm@0.2.0
Enter fullscreen mode Exit fullscreen mode

Add the test command to package.json.

{
  "type": "module",
  "scripts": {
    "test": "rspec-wasm"
  },
  "dependencies": {
    "mpg123-decoder": "1.0.3"
  },
  "devDependencies": {
    "rspec-wasm": "0.2.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Our project will have the following structure:

.
├── lib/
│   └── audio_probe.rb
├── spec/
│   ├── fixtures/
│   │   └── tone.mp3
│   └── audio_probe_spec.rb
├── package.json
└── rspec-wasm.config.mjs
Enter fullscreen mode Exit fullscreen mode

2. Prepare an MP3 File for Testing

For this example, we'll use a stereo MP3 file with a sample rate of 44.1 kHz.

Download the test file using the following commands:

mkdir -p spec/fixtures
curl -L https://github.com/eshaz/wasm-audio-decoders/raw/refs/heads/main/test/data/44100.stereo.cbr.mp3 \
  -o spec/fixtures/tone.mp3
Enter fullscreen mode Exit fullscreen mode

This file comes from the test data in eshaz/wasm-audio-decoders, which is distributed under the MIT license.

If you use a different MP3 file, adjust the expected sample rate and channel count in the tests accordingly.

3. Initialize the Wasm Decoder Before Running RSpec

Now comes the main feature introduced in v0.2.0.

Create rspec-wasm.config.mjs to define the setup and teardown operations.

import fs from "node:fs/promises";
import path from "node:path";
import { MPEGDecoder } from "mpg123-decoder";

let decoder;

function summarize(decoded, encodedBytes) {
  const peakLevels = decoded.channelData.map((channel) => {
    let peak = 0;

    for (const sample of channel) {
      peak = Math.max(peak, Math.abs(sample));
    }

    return peak;
  });

  return {
    encoded_bytes: encodedBytes,
    sample_rate: decoded.sampleRate,
    channels: decoded.channelData.length,
    samples_per_channel: decoded.samplesDecoded,
    duration_ms: Math.round(
      decoded.samplesDecoded / decoded.sampleRate * 1000
    ),
    peak_levels: peakLevels,
    errors: decoded.errors.map((error) => error.message),
  };
}

export default {
  async setup({ workspaceRoot }) {
    decoder = new MPEGDecoder();
    await decoder.ready;

    globalThis.audioCodec = {
      async inspect(relativePath) {
        const root = path.resolve(workspaceRoot);
        const filePath = path.resolve(root, relativePath);
        const relative = path.relative(root, filePath);

        if (relative.startsWith("..") || path.isAbsolute(relative)) {
          throw new Error("Audio file must be inside the workspace");
        }

        const encoded = await fs.readFile(filePath);

        try {
          const decoded = decoder.decode(encoded);
          return JSON.stringify(
            summarize(decoded, encoded.byteLength)
          );
        } finally {
          await decoder.reset();
        }
      },
    };
  },

  async teardown() {
    decoder?.free();
    delete globalThis.audioCodec;
  },
};
Enter fullscreen mode Exit fullscreen mode

The setup function initializes the Wasm decoder and waits until it's ready before RSpec starts.

We expose the initialized API through globalThis.audioCodec, allowing Ruby code to access it through the JavaScript bridge.

After the tests finish, teardown releases the decoder.

The execution flow looks like this:

Create Ruby VM
      |
      v
Asynchronously initialize
the MP3 Wasm decoder
      |
      v
Run RSpec tests
      |
      v
Release the decoder
Enter fullscreen mode Exit fullscreen mode

This lifecycle management is particularly useful for external Wasm modules that require asynchronous initialization.

4. Await a JavaScript Promise from Ruby

Next, create lib/audio_probe.rb.

require "json"
require "js"

class AudioProbe
  def self.inspect(path)
    result = JS.global[:audioCodec]
      .call(:inspect, path)
      .await
      .to_s

    JSON.parse(result, symbolize_names: true)
  end
end
Enter fullscreen mode Exit fullscreen mode

The JavaScript inspect function returns a Promise, so we use .await on the Ruby side to wait for the result.

In v0.2.0, rspec-wasm executes RSpec through RubyVM#evalAsync, allowing Ruby code inside specs to await JavaScript Promises.

Another important detail is that the MP3 binary data and decoded PCM data remain on the JavaScript/Wasm side.

Only a small JSON summary is returned to Ruby.

This avoids unnecessarily transferring large binary data across the Ruby–JavaScript boundary.

5. Write the RSpec Tests

Now let's create spec/audio_probe_spec.rb.

require "audio_probe"

RSpec.describe AudioProbe do
  it "decodes MP3 metadata with a WebAssembly codec" do
    result = described_class.inspect("spec/fixtures/tone.mp3")

    expect(result[:encoded_bytes]).to be > 0
    expect(result[:sample_rate]).to eq(44_100)
    expect(result[:channels]).to eq(2)
    expect(result[:samples_per_channel]).to be > 0
    expect(result[:duration_ms]).to be > 0
    expect(result[:peak_levels].first).to be_between(0.0, 1.0)
    expect(result[:errors]).to be_empty
  end

  it "rejects parent-directory traversal" do
    expect {
      described_class.inspect("../outside.mp3")
    }.to raise_error(JS::Error, /inside the workspace/)
  end
end
Enter fullscreen mode Exit fullscreen mode

The first test actually decodes an MP3 file using WebAssembly and verifies properties such as the sample rate, channel count, and audio duration.

The second test verifies that the JavaScript API rejects attempts to access files outside the workspace using ../.

JavaScript exceptions are exposed as JS::Error on the Ruby side, so we can test them using the usual RSpec matchers.

A note about path validation: This example uses lexical path validation with path.relative. If you need to enforce workspace boundaries even when symbolic links are involved, you should also validate the resolved paths using fs.realpath.

For this demonstration, we'll keep things simple.

6. Run the Tests

Finally, run:

npm test
Enter fullscreen mode Exit fullscreen mode

The output should look similar to this:

AudioProbe
  decodes MP3 metadata with a WebAssembly codec
  rejects parent-directory traversal

Finished in 0.10502 seconds (files took 1.3 seconds to load)
2 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

2 examples, 0 failures!

We've successfully tested Ruby code that calls JavaScript, invokes a WebAssembly MP3 decoder, and returns the result to RSpec.

And we can use ordinary RSpec matchers throughout the process.

What's New in rspec-wasm v0.2.0?

Version 0.2.0 introduces the following features:

  • Configuration through rspec-wasm.config.mjs — Define JavaScript-side initialization and cleanup behavior.
  • Asynchronous setup and teardown — Initialize external runtimes before tests and release resources afterward.
  • Promise support in Ruby specs — Call JavaScript functions that return Promises and wait for their results using .await.
  • Reliable cleanup — Run teardown operations whether RSpec tests pass or fail.
  • Initialization failure handling — If setup fails, RSpec won't start, and the process exits with a non-zero status.

The MP3 decoder example demonstrates the first three features directly.

Conclusion

With rspec-wasm v0.2.0, we can now test more than just Ruby code running entirely inside ruby.wasm.

We can also test Ruby applications that use JavaScript as their host environment and integrate functionality from external WebAssembly modules.

I think combining multiple Wasm modules is one of the interesting possibilities of WebAssembly-based application development.

By supporting asynchronous runtime initialization and Promise handling, rspec-wasm makes it easier to write RSpec tests for these applications without moving test logic into JavaScript.

If you're building applications with ruby.wasm and other Wasm modules, give rspec-wasm a try!

References

Top comments (0)