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
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"
}
}
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
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
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;
},
};
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
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
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
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
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
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
- rspec-wasm on npm
- ruby.wasm on GitHub
- RSpec
- mpg123-decoder on npm
- My previous DEV article: Stop Writing Ruby Tests in JS Template Literals: Meet rspec-wasm
rspec-wasm が JavaScript / Wasm ランタイムとの連携に対応しました
2026年9月にリリースしてこちらに投稿した rspec-wasm をバージョンアップしましたので紹介します。
ruby.wasm で作ったアプリを RSpec でテストできるようにする rspec-wasm を作りました
rspec-wasm v0.2.0
v0.2.0 では、RSpec実行前後に非同期 JavaScript 処理を実行できるようにしました。
Wasm でアプリケーションを作る場合、様々な Wasm を組み合わせられることが大きなメリットだと思います。しかしながら、これまでの rspec-wasm には、RSpec 実行前に外部ランタイムを非同期で初期化し、実行後に解放する仕組みがありませんでした。
v0.2.0 ではその点を改善し、RSpec 実行前に JavaScript や Wasm で提供される外部ランタイムを初期化することで、それらを呼び出す Ruby のコードでも、RSpec でテストすることができるようになりました。
外部の Wasm を使うケース
ruby.wasm でアプリケーションを作る場合、Wasm のビルドまで行うのであれば可能な範囲は増えます。一方、ruby.wasm で Gem をそのまま利用する場合、現状は Pure Ruby の Gem が対象です。そのため、使いたい機能の Gem が見つからなかったり、期待する性能が得られなかったりすることが考えられます。
もちろん、そもそも複数の Wasm の機能を組み合わせて作る前提のアプリケーションということもあると思います。
これらを整理すると、ruby.wasm から外部の Wasm を使うのは、例えば以下のようなケースにまとめられると思います。
- 複数の Wasm の機能を組み合わせることが前提になっている場合。
- 使いたい機能の Pure Ruby の Gem は無いが、外部 Wasm はある場合。
- 期待する性能の実現が ruby.wasm だけでは難しいが、外部 Wasm を使えば実現できる場合。
MP3 をデコードするサンプル
ここでは、外部 Wasm を使う例として、Wasm版MP3デコーダーを初期化し、Rubyから音声情報を取得する処理を、RSpecでテストしてみます。
ちなみに、ruby.wasm から外部 Wasm を使う例は、以下のようなものも考えられます。
- 画像・動画処理
- SQLiteなどのデータベース
- Python/Wasmランタイム
- 圧縮・暗号化ライブラリ
- 数値計算や機械学習ランタイム
使用するもの
- Node.js 18以上
- rspec-wasm 0.2.0
- mpg123-decoder 1.0.3
なお、ruby.wasm や RSpec は rspec-wasm に入っています。
mpg123-decoderは、mpg123をWebAssemblyで動かすMP3デコーダーです。
プロジェクトを作成する
では、サンプルプロジェクトを作ってみましょう。
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
package.jsonへテストコマンドを追加します。
{
"type": "module",
"scripts": {
"test": "rspec-wasm"
},
"dependencies": {
"mpg123-decoder": "1.0.3"
},
"devDependencies": {
"rspec-wasm": "0.2.0"
}
}
ディレクトリ構成は次のようにします。
.
├── lib/
│ └── audio_probe.rb
├── spec/
│ ├── fixtures/
│ │ └── tone.mp3
│ └── audio_probe_spec.rb
├── package.json
└── rspec-wasm.config.mjs
テスト用の MP3 ファイルを用意する
このサンプルでは、44.1 kHz・ステレオの MP3 ファイルを使用します。次のコマンドでテスト用ファイルを配置します。
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
このファイルは、MIT ライセンスで公開されている eshaz/wasm-audio-decoders のテストデータです。別の MP3 ファイルを使用する場合は、後述する sample rate や channel 数の期待値をそのファイルに合わせて変更してください。
RSpec実行前にWasmデコーダーを初期化する
RSpec を実行する前後の処理を設定する rspec-wasm.config.mjs を作成します。
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;
},
};
setupでは、RSpecが始まる前にWasmデコーダーの準備完了を待ちます。
初期化したAPIはglobalThis.audioCodecへ公開します。RSpec終了後はteardownでデコーダーを解放します。
処理順は次のとおりです。
Ruby VMを生成
↓
MP3 Wasmデコーダーを非同期初期化
↓
RSpecを実行
↓
デコーダーを解放
JavaScript の Promise で返るデータを待つ
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
JavaScriptのinspectはPromiseを返すため、Ruby側では.awaitで完了を待ちます。
v0.2.0ではRSpec本体をRubyVM#evalAsync経由で実行しているため、spec内からPromiseを待てます。
MP3のバイナリデータやデコード後のPCMデータはJavaScript/Wasm側に置いたまま、Rubyへは小さなJSONだけを返しています。
RSpecを書く
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
1つ目のテストでは、MP3を実際にWasmでデコードし、サンプルレートやチャンネル数などを検証しています。
2つ目では、../によるワークスペース外へのパストラバーサルを拒否することを確認しています。JavaScript側の例外はRuby側でJS::Errorとして扱えます。
このサンプルの検証は、path.relativeを使った字句的なパス検証です。シンボリックリンクを含めて実際のファイル位置を制限する必要がある場合は、fs.realpathで解決したパスも検証したほうが良いですが、ここでは簡単に。
実行する
npm test
実行結果は次のようになります。
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
RubyのspecからJavaScriptを経由してMP3をWasmデコードし、その結果を通常のRSpec matcherで検証できました。
まとめ
rspec-wasm v0.2.0 では次の機能を追加し、サンプルでは上から3つの機能を使いました。
- rspec-wasm.config.mjsによる設定
- 非同期 setup と teardown
- Ruby specからPromiseを返すJavaScript関数を呼び、.awaitで結果を待つ
- RSpecの成功・失敗にかかわらず終了処理を実行する
- 初期化に失敗した場合はRSpecを開始せず、非ゼロで終了する
rspec-wasm v0.2.0では、Rubyだけで完結するコードに加えて、JavaScriptをホストとして外部Wasmモジュールと連携するコードもRSpecでテストできるようになりました。
様々な Wasm を活用した ruby.wasm のアプリケーション作成に、ぜひ活用してください。
参考
- rspec-wasm
- ruby.wasm
- RSpec
- mpg123-decoder
Mentions
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 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" } } 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 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 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; }, }; 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 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 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 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 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 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 - rspec-wasm on npm - ruby.wasm on GitHub - RSpec - mpg123-decoder on npm - My previous DEV article: Stop Writing Ruby Tests in JS Template Literals: Meet rspec-wasm
Rubyをブラウザでリアルタイムに試せる、WebAssembly製の「Ruby Playground」を作った はじめに こんにちは、@glassmonekey です。 Ruby をインストールせずに、ブラウザでコードを書いて試せる Ruby Playground を公開しました。コードの変更に応じて自動実行する playground に加え、説明を読みながらコードを動かせるチュートリアルも用意しています。 ruby-play.dev 背景・モチベーション 前回の記事で書いた転職先では、Ruby がメインの言語です。Go や PHP の経験は長いものの、Ruby は未経験なので、入社前に手を動かして慣れておきたいと思いました。いわゆる論理無職で時間があったこともあり、せっかくなら勉強がてら、書いて試せる環境を作ってみることにしました。 zenn.dev コードと結果に集中できる環境 Ruby を学ぶにあたり、コードを書き換えて結果を確かめる試行に集中したいと考えました。スマホからも使えるよう、ブラウザで動くことも条件にしました。 ブラウザで Ruby を実行できるサービスはありますが、実行ボタンを押す形式では、変更のたびに操作が必要です。初学者の自分は、コードと結果の確認に集中できるよう、この一手間を挟まずに試せる形を求めていました。 以前に PHP Playground を作った経験があり、Ruby にも公式の ruby.wasm が提供されていたため、実現できそうだと判断して WebAssembly 製の Ruby Playground を作ることにしました。 php-play.dev 学習と実行をつなぐチュートリアル Ruby の文法に不慣れな自分には、説明とコードを見比べながら学べる教材も必要でした。説明を読むだけでなく、例を書き換え、結果を確かめながら理解を深めたいと考えました。 Ruby をブラウザで学べる教材として、公式サイトでも紹介されている TryRuby があります。説明を読みながらコードを試せますが、サンプルをコピーしてエディタに貼り付ける一手間があります。自分が欲しかったのは、その操作を挟まず、用意された例をそのまま編集して試せる形でした。 try.ruby-lang.org 一方、Go には「A Tour of Go」というチュートリアルサイトがあります。説明の横にサンプルコードが用意され、その場で書き換えて実行できます。自分も以前に使って、学びやすいと感じた教材です。 go-tour-jp.appspot.com そこで、A Tour of Go の画面構成を参考に、Ruby Playground の実行環境を組み合わせたチュートリアルを作ることにしました。 PHP Playground では、利用の約9割が日本以外からでした。その経験も踏まえ、Ruby Playground も世界の人に使ってもらえるよう、教材の説明を英語にしました。 何を作ったか 自由に Ruby のコードを試せる playground を作りました。説明を読みながら学べるチュートリアルも用意し、そこで気になったコードを playground に引き継いで、さらに試せるようにしました。 機能と対応範囲 playground コードを書き換えて結果を確かめる試行を続けられるよう、変更に応じて自動実行する形にしました。 最初のサンプルでは、Ruby で「ruby」を表示しています。コードの変更が結果に反映される様子を、以下のデモで紹介します。 試したい条件や使う端末に合わせられるよう、次の機能も用意しました。 バージョンの切り替え:同じコードを複数のバージョンで試すため、Ruby 4.0、3.4、3.3、3.2 と、開発版の Ruby 4.1 を選べるようにしました。 URL での共有:コードと選択したバージョンを URL に含め、共有相手も同じ条件で試せるようにしました。コードスニペットの共有に気軽に使ってください。 スマホでの表示:小さな画面でもコードと結果を確認できるよう、エディタと出力を縦に並べました。 今回は、自分の Ruby 学習を目的に、playground とチュートリアルの制作に注力しました。対応範囲は、例えば次のように決めています。 扱う機能:Ruby の基本を学ぶため、gem の追加なしでコードを試す構成にしました。 対応バージョン:学習環境の制作に集中できるよう、ruby.wasm の公式ビルドを利用できるバージョンを対象にしました。 チュートリアル Ruby の基本的な言語仕様を扱うチュートリアルを用意しました。読み進めるだけでなく、自分で確かめながら学べる構成を目指しました。 ruby-play.dev 教材では、次の点を意識しました。 既習内容を使って進む順序:値や変数から始め、クラスや例外などへ進みます。前のレッスンで扱ったことを使いながら、新しい内容に取り組める順番にしました。 理解を確かめる課題:説明とサンプルに加え、コードを書き換える課題を用意しました。答えを読むのではなく、自分で実行して結果を確かめる形です。 課題の範囲を超えて試行を続けられるよう、書きかけのコードと選んだバージョンを playground に引き継げる形にしました。 また、教材の説明と実際の挙動を対応させて学べるよう、選んだ Ruby のバージョンに合わせて説明・サンプル・実行環境を切り替えています。 実現方法 大枠の構成は、以前に作った PHP Playground を参考にしました。 php-play.dev PHP Playground の開発・運用については、「PHP Playgroundを約1年間運用してみて」をご覧ください。 zenn.dev Ruby Playground では、画面の操作とコードの実行を分け、ブラウザ内で完結させる構成にしています。実行環境には、Ruby を WebAssembly(Wasm)で動かす ruby.wasm の公式ビルドを使っています。 ruby/ruby.wasm コードの実行と結果の表示 コードやバージョンを変更すると、ブラウザ内で Ruby を実行し、出力やエラーを画面へ返します。 Ruby の実行を Web Worker に分けたのは、実行中もエディタを操作できるようにするためです。無限ループのように処理が終わらないコードを試す場合も、画面側から実行を打ち切り、修正を続けられるようにしました。 変更時は前の Worker を終了し、新しい Worker と Ruby VM で実行し直します。停止操作やタイムアウトでも Worker を終了します。やり取りの流れは次のとおりです。 変更前の結果が遅れて届いても、新しい出力を上書きしないよう、画面側でも実行を識別して古い結果を無視しています。 URL によるコードの共有 コードを保存するサーバーを用意せずに共有できるよう、コードと選んだバージョンを URL に含めました。 共有時:コードを圧縮し、URL に含められる URL セーフな文字列に変換します。バージョンとともにアドレスバーへ反映するので、その URL をコピーして共有できます。 復元時:URL からコードを展開してエディタに戻し、選ばれたバージョンも復元します。共有相手も同じコードとバージョンで試せます。 例えば、次のように金額を合計する短いコードも共有できます。 prices = [120, 250, 380] puts prices.sum このスニペットを Ruby Playground で開く 開くと合計の 750 が表示されます。受け取った人は金額を変えたり要素を足したりして、結果を確かめられます。試したコードを誰かに伝えたいときは、自分のスニペットでも URL を共有してみてください。 作ってみた感想 作ってみて、Ruby への理解を深めながら、自分の学習をほかの人にも使ってもらえるサービスにできたのがよかったと感じています。 教材の説明やサンプルの根拠を確認するためにリファレンスを読む機会が増え、Ruby にはかなり慣れた気がします。挙動を説明できるように調べることが、自分の理解にもつながりました。参考として、公式日本語リファレンスを載せておきます。 docs.ruby-lang.org 慣れた言語の感覚を捉え直す場面もありました。例えば、確認用の puts を末尾に足すと、表示だけでなく map の戻り値まで変わります。 p [1, 2].map { |n| n * 2 } # => [2, 4] p [1, 2].map { |n| n * 2; puts n * 2 } # => [nil, nil](2 と 4 も出力) 今回のチュートリアルの考え方を、以前に作った PHP Playground にも取り入れられました。 php-play.dev おわりに Ruby をこれから学ぶ方は、まずはサンプルを書き換えて、結果の変化を試してみてください。 ruby-play.dev 説明を読みながら順に学びたい方には、チュートリアルも用意しています。こちらもぜひ遊んでみてください。 ruby-play.dev 今後は、試せる範囲や学び方の選択肢を広げるため、例えば次のような機能追加を考えています。 gem を追加できるようにする 独自のビルドの仕組みを整え、Ruby 3.2 より前のバージョンも動かせるようにする チュートリアルを日本語でも読めるようにする 機能を改善するだけでなく、Ruby を試す環境を探している人にも届けたいので、検索で 1 位を目指します。 使ってみた感想や要望も、ぜひ教えてください。X の @glassmonekey か、GitHub のリポジトリの issue でフィードバックをもらえるとうれしいです。 glassmonkey/ruby-playground
rspec-wasm が JavaScript / Wasm ランタイムとの連携に対応しました 2026年9月にリリースしてこちらに投稿した rspec-wasm をバージョンアップしましたので紹介します。 ruby.wasm で作ったアプリを RSpec でテストできるようにする rspec-wasm を作りました rspec-wasm v0.2.0 v0.2.0 では、RSpec実行前後に非同期 JavaScript 処理を実行できるようにしました。 Wasm でアプリケーションを作る場合、様々な Wasm を組み合わせられることが大きなメリットだと思います。しかしながら、これまでの rspec-wasm には、RSpec 実行前に外部ランタイムを非同期で初期化し、実行後に解放する仕組みがありませんでした。 v0.2.0 ではその点を改善し、RSpec 実行前に JavaScript や Wasm で提供される外部ランタイムを初期化することで、それらを呼び出す Ruby のコードでも、RSpec でテストすることができるようになりました。 外部の Wasm を使うケース ruby.wasm でアプリケーションを作る場合、Wasm のビルドまで行うのであれば可能な範囲は増えます。一方、ruby.wasm で Gem をそのまま利用する場合、現状は Pure Ruby の Gem が対象です。そのため、使いたい機能の Gem が見つからなかったり、期待する性能が得られなかったりすることが考えられます。 もちろん、そもそも複数の Wasm の機能を組み合わせて作る前提のアプリケーションということもあると思います。 これらを整理すると、ruby.wasm から外部の Wasm を使うのは、例えば以下のようなケースにまとめられると思います。 - 複数の Wasm の機能を組み合わせることが前提になっている場合。 - 使いたい機能の Pure Ruby の Gem は無いが、外部 Wasm はある場合。 - 期待する性能の実現が ruby.wasm だけでは難しいが、外部 Wasm を使えば実現できる場合。 MP3 をデコードするサンプル ここでは、外部 Wasm を使う例として、Wasm版MP3デコーダーを初期化し、Rubyから音声情報を取得する処理を、RSpecでテストしてみます。 ちなみに、ruby.wasm から外部 Wasm を使う例は、以下のようなものも考えられます。 - 画像・動画処理 - SQLiteなどのデータベース - Python/Wasmランタイム - 圧縮・暗号化ライブラリ - 数値計算や機械学習ランタイム 使用するもの - Node.js 18以上 - rspec-wasm 0.2.0 - mpg123-decoder 1.0.3 なお、ruby.wasm や RSpec は rspec-wasm に入っています。 mpg123-decoderは、mpg123をWebAssemblyで動かすMP3デコーダーです。 プロジェクトを作成する では、サンプルプロジェクトを作ってみましょう。 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 package.jsonへテストコマンドを追加します。 { "type": "module", "scripts": { "test": "rspec-wasm" }, "dependencies": { "mpg123-decoder": "1.0.3" }, "devDependencies": { "rspec-wasm": "0.2.0" } } ディレクトリ構成は次のようにします。 . ├── lib/ │ └── audio_probe.rb ├── spec/ │ ├── fixtures/ │ │ └── tone.mp3 │ └── audio_probe_spec.rb ├── package.json └── rspec-wasm.config.mjs テスト用の MP3 ファイルを用意する このサンプルでは、44.1 kHz・ステレオの MP3 ファイルを使用します。次のコマンドでテスト用ファイルを配置します。 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 このファイルは、MIT ライセンスで公開されている eshaz/wasm-audio-decoders のテストデータです。別の MP3 ファイルを使用する場合は、後述する sample rate や channel 数の期待値をそのファイルに合わせて変更してください。 RSpec実行前にWasmデコーダーを初期化する RSpec を実行する前後の処理を設定する rspec-wasm.config.mjs を作成します。 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; }, }; setupでは、RSpecが始まる前にWasmデコーダーの準備完了を待ちます。 初期化したAPIはglobalThis.audioCodecへ公開します。RSpec終了後はteardownでデコーダーを解放します。 処理順は次のとおりです。 Ruby VMを生成 ↓ MP3 Wasmデコーダーを非同期初期化 ↓ RSpecを実行 ↓ デコーダーを解放 JavaScript の Promise で返るデータを待つ 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 JavaScriptのinspectはPromiseを返すため、Ruby側では.awaitで完了を待ちます。 v0.2.0ではRSpec本体をRubyVM#evalAsync経由で実行しているため、spec内からPromiseを待てます。 MP3のバイナリデータやデコード後のPCMデータはJavaScript/Wasm側に置いたまま、Rubyへは小さなJSONだけを返しています。 RSpecを書く 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 1つ目のテストでは、MP3を実際にWasmでデコードし、サンプルレートやチャンネル数などを検証しています。 2つ目では、../によるワークスペース外へのパストラバーサルを拒否することを確認しています。JavaScript側の例外はRuby側でJS::Errorとして扱えます。 このサンプルの検証は、path.relativeを使った字句的なパス検証です。シンボリックリンクを含めて実際のファイル位置を制限する必要がある場合は、fs.realpathで解決したパスも検証したほうが良いですが、ここでは簡単に。 実行する npm test 実行結果は次のようになります。 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 RubyのspecからJavaScriptを経由してMP3をWasmデコードし、その結果を通常のRSpec matcherで検証できました。 まとめ rspec-wasm v0.2.0 では次の機能を追加し、サンプルでは上から3つの機能を使いました。 - rspec-wasm.config.mjsによる設定 - 非同期 setup と teardown - Ruby specからPromiseを返すJavaScript関数を呼び、.awaitで結果を待つ - RSpecの成功・失敗にかかわらず終了処理を実行する - 初期化に失敗した場合はRSpecを開始せず、非ゼロで終了する rspec-wasm v0.2.0では、Rubyだけで完結するコードに加えて、JavaScriptをホストとして外部Wasmモジュールと連携するコードもRSpecでテストできるようになりました。 様々な Wasm を活用した ruby.wasm のアプリケーション作成に、ぜひ活用してください。 参考 - rspec-wasm - ruby.wasm - RSpec - mpg123-decoder