はじめに
テストスイートを育てていくと、必ずぶつかるのが「実行が遅い」という悩みです。テストファイルが数十、数百と増えるにつれ、npm testのたびにコーヒーを一杯淹れられるくらい待たされる、なんてことも珍しくありません。
原因の多くは、テストランナーが「1つずつ順番に」実行する前提で作られていることにあります。JestやMochaも並列実行の仕組みは持っていますが、デフォルトの動作としては直列実行が基本です。
Avaはこの前提そのものをひっくり返したテストランナーです。テストファイルはそれぞれ別プロセスで実行され、1つのファイル内のテストもデフォルトで並行に走ります。「テストは上から下へ順番に実行するもの」という思い込みを外すと、IOを含むテストほど恩恵が大きいことに気づきます。
Avaとは
AvaはNode.js向けのテストランナーです。GitHubで20,000スター以上を獲得しており、avajs組織のもとで活発に開発が続いています。シンプルなAPIと、失敗時に差分をそのまま見せてくれる「Magic Assert」が特徴で、Node.js標準のassertやJestのような大掛かりな設定なしに使い始められます。
主な特徴
- 並行実行がデフォルト - 1つのテストファイル内の各テストは、明示的に指定しない限り並行に実行されます。テストファイル自体も別プロセスで動くため、テスト同士の状態汚染も起きません
- Magic Assert -
t.is()やt.deepEqual()が失敗すると、期待値と実際の値の差分をわかりやすく表示してくれます - TypeScript定義を同梱 - 型定義がパッケージに含まれており、TypeScriptプロジェクトでも追加の型パッケージなしに使えます
- ESMファースト - ECMAScript Modulesを前提とした設計で、
import/exportをそのまま書けます - スナップショットテスト -
t.snapshot()でオブジェクトの形をスナップショットとして保存し、意図しない変化を検出できます - CI環境の自動検出 - CI上ではプロセス数を自動で調整し、環境に合わせた並行度でテストを実行します
インストール
npm init avaを実行すると、package.jsonへの設定追加までまとめて行われます。
npm init ava
既存プロジェクトに手動で追加する場合は、開発依存としてインストールします。
npm install --save-dev ava
Yarnやpnpmでも同様です。
yarn add --dev ava
pnpm add --save-dev ava
基本的な使い方
Avaはtest関数をインポートし、第一引数にテスト名、第二引数にテスト本体を渡すだけで書き始められます。tにはアサーションメソッドがまとまっています。
// test.js
import test from 'ava';
test('文字列の一致を確認する', t => {
t.is('ava', 'ava');
});
test('オブジェクトの一致を確認する', t => {
t.deepEqual({ name: 'Alice' }, { name: 'Alice' });
});
test('真偽値を確認する', t => {
t.true(1 + 1 === 2);
});
test('非同期処理を待って確認する', async t => {
const value = await Promise.resolve('bar');
t.is(value, 'bar');
});
package.jsonのscriptsに"test": "ava"を登録しておけば、npm testだけで実行できます。
npx ava
# または
npm test
t.is()やt.deepEqual()が失敗すると、期待値と実際の値の差分がターミナルにそのまま表示されるため、console.logを差し込んで原因を探る手間が減ります。
実践的なユースケース
並行実行と実行順序の制御
Avaは同一ファイル内のテストをデフォルトで並行実行しますが、DBの初期化とデータ投入のように順序が意味を持つ処理ではtest.serialを使って直列実行に切り替えられます。
import test from 'ava';
import { resetDatabase, seedUsers, countUsers } from '../src/db.js';
test.serial('DBをリセットしてユーザーを投入する', async t => {
await resetDatabase();
await seedUsers(3);
t.pass();
});
test.serial('投入した件数を確認する', async t => {
const count = await countUsers();
t.is(count, 3);
});
test.serialを付けたテストは、通常のtestより先に、かつ記述順に実行されます。並行実行が原因で状態が競合するテストだけを狙ってこのモードに切り替えるのがコツです。
フックとt.contextによる共通セットアップ
複数のテストで共通の初期化処理が必要な場合は、test.beforeEachとt.contextを組み合わせます。t.contextはテストごとに独立したオブジェクトなので、並行実行していても値が混ざりません。
import test from 'ava';
test.beforeEach(t => {
t.context.cart = { items: [], total: 0 };
});
test('カートに商品を追加できる', t => {
t.context.cart.items.push({ name: 'apple', price: 100 });
t.context.cart.total += 100;
t.is(t.context.cart.total, 100);
});
test('空のカートは合計が0になる', t => {
t.is(t.context.cart.total, 0);
});
test.before・test.afterをファイル全体の1回だけの処理に、test.beforeEach・test.afterEachをテストごとの処理に使い分けます。
スナップショットテストで意図しない変化を検出する
APIレスポンスの整形結果やUIコンポーネントの出力など、「形が変わっていないこと」を確認したい場面ではt.snapshot()が便利です。初回実行時にスナップショットファイルへ保存し、以降はその内容と比較します。
import test from 'ava';
import { formatUserProfile } from '../src/user.js';
test('ユーザープロフィールの整形結果が変わっていないか', t => {
const profile = formatUserProfile({ name: 'Alice', age: 30 });
t.snapshot(profile);
});
意図した変更であれば、npx ava --update-snapshotsでスナップショットを更新します。レビュー時に差分としてスナップショットファイルの変更が見えるため、意図しない変化に気づきやすくなります。
まとめ
Avaは、テストファイルのプロセス分離と並行実行をデフォルトにすることで、テストスイートが大きくなっても実行時間が線形に伸びにくい設計になっています。t.is()やt.deepEqual()が示すMagic Assertの差分表示、TypeScript定義の同梱、t.snapshot()によるスナップショットテストなど、必要な機能がひと通り揃っている点もJestやMochaからの移行を後押ししてくれます。
既存のNode.jsプロジェクトで「テストの実行が遅い」と感じているなら、まずはnpm init avaで小さなテストファイルを1本書いてみて、test.serialやフックへと段階的に広げていくのがおすすめです。
