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-wasm0.2.0 -
mpg123-decoder1.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 のアプリケーション作成に、ぜひ活用してください。