備忘録: Mochaによるテストの基本構造

Mocha

はじめに

Mochaのフレームワークに従うテストコードの構造とコールバックに関する備忘録。基本ではあるが、理解していないと思わぬところで足をすくわれるかも知れない。(筆者も含めて) なんとなく使っているという人は、一度基本を確認して見るのも悪くはないかも。

Mochaとは何か

そもそもの話として …

MochaはJavaScriptを対象としたテストフレームワーク。

Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun.

https://mochajs.org

テスト対象は、ローカルにある単体の関数からリモートのRest APIまで、テストコードから呼び出し可能な機能であれば何でも良い。

テストコードは、テスターがJavaScriptのコードとして記述する。Mochaはテストコードの実行や、テスト結果の報告を管理してくれる。

Mochaの特徴は、テストコードにテスト仕様を記述できること。これによりTDD (Test Driven Development) やBDD (Behavior Driven Development) といった開発プロセスとの親和性が高いテストができる。それぞれのスタイルに対応したインタフェース(語彙)が用意されている。

Mochaの実行

MochaはCLI (Command Line Interface) として実行できる。コマンドラインからMochaを実行すると、テストコードの記述されたファイルを読み込み実行する。

Mochaに読み込むファイルの指定には様々な方法がある。デフォルトは./testディレクトリにあるファイルとなる。対象のディレクトリに複数のファイルがあれば、まとめて実行してくれる。逆に個別に指定することもできる。

コマンドラインからの実行の他、ブラウザの中でも利用できる。

Nodeの開発環境でのMochaの実行

Nodeの開発環境では、ローカルにインストールしたMochaの実行ファイルは下記の場所にある。

./node_modules/mocha/bin/mocha

これはnpmからだと簡単に起動できる。npmでインストールされたモジュールにbinディレクトリがあると、./node_modules/.binの下に元のbinディレクトリのコマンドを起動するスクリプトが作成される。npmの実行コンテキストでは、このディレクトリにパスが通っているため、npmからは対象のスクリプトを直接起動できる。Mochaを実行したいディレクトリへの変更も含めて、package.jsonに下記のように記述すれば良い。

scripts: {
    test: "cd <dir> & mocha"
}

これを起動するにはコマンドラインから下記のように実行する。

npm test

VS Codeでのデバッグ

MochaのテストコードをVS Codeでデバックする方法が解説されている。

vscode-recipes/debugging-mocha-tests at main · microsoft/vscode-recipes
Contribute to microsoft/vscode-recipes development by creating an account on GitHub.

Mochaによるテストコードの基本構造

ここからが本題。

Mochaに読み込まれるテストコードは下記のようなdescribeitが組み合わさった構造を持つ。

describe('テストの大見出し', function() {
    describe('テストの小見出し', function() {
        it('テスト内容の説明', function() {
            // テストコード
            //   機能の実行
            //   機能の検証
            //   例外の送出 (もし検証結果が偽であれば)
        });
        it('テスト内容の説明', function() {
            // テストコード
            //   機能の実行
            //   機能の検証
            //   例外の送出 (もし検証結果が偽であれば)
        });
    });
    describe('テストの小見出し', function() {
    });
});

因みにdescribeitはBDD向けのインタフェース。TDDだとそれぞれsuitetestになる。

it

itはテストの最小単位である。itの中でテスト対象の機能を実行し、その結果を検証する。検証結果のエラーは、例外として通知される必要がある。itはその例外をキャッチして、テスト結果として保存し、テスト実行後にレポートする。次に記述するdescribeと違い、ネストすることはできない。

describe

describeは、複数のitをまとめられる。また階層化(ネスト)してテストを構造化できる。

describeには、before/after/beforeEach/afterEachといったHookを設定できる。Hookはユーザーの用意する関数で、テストの前処理、後処理を行える。

before/afterdescribeの実行の前処理、後処理であり、beforeEach/afterEachは、describeの中の各itの実行に対する前処理、後処理となる。

itごとに前処理、後処理の内容を変えたい時はどうするのか? beforeEach/afterEachの中で工夫できるかも知れないが、素直に1つのdescribeに1つのitを割り当てるほうが良さそう。

コールバック

itの中のテストコードは、コールバックとして記述されることに注意する。itのブロック間にコードを記述することは可能だが、実行順は見かけ上の記述の順番にはならない。これはdescribeについても同様である。ブロックという呼び方も誤解を招くかも知れない。itdescribeは関数だし、それらのコールバックの関数なので、ブログラム内の{}によるブロックとは異なる。

describeitで、コールバックの実行順序は下記のようになる。

  • describeのコールバックは、そのdescribeの中で実行される
    • describeがネストしている場合は、describeのコールバックの実行もネストする (親->子->親)
  • itのコールバックは、全てのdescribeが処理されたあとに実行される
  • 同じdescribeの中にあるitのコールバックは、定義されている順番に実行される
  • describeがネストしている場合は、親のレベルのitのコールバックが全て実行されたあとに、子のレベルのitのコールバックが実行される
    • describeのネストとは実行順が異なる

この確認に使ったテストプログラムを下記に示す。

途中変数valueに値を入れ直しながら、コールバックの中で参照している。valueの値は、当然ながら上記のルールに従って変化する。

describeitでコールバックの実行タイミングが異なるところが要注意。各itの中で閉じた処理を書くように心がけていれば問題はないはずだが、itから外側のdescribeで定義された変数を参照するようなケースでは要注意である。

let value = '!!';
console.log(`世界の始まり - ${value}`);
describe('実行順の確認A-C', function () {
    console.log(`A-Cの始まり - ${value}`);
    value = 'A';
    it('テストA', function () {
        console.log(`テストAの中 - ${value}`);
        value = 'AA';
    });
    console.log(`A-Bの中間 - ${value}`);
    value = 'B';
    it('テストB', function () {
        console.log(`テストBの中 - ${value}`);
        value = 'BB';
    });
    describe('小世界', function () {
        console.log(`小世界 - ${value}`);
        it('小世界のテスト', function () {
            console.log(`小世界の中 - ${value}`);
        });
    })
    value = 'C'
    console.log(`B-Cの中間 - ${value}`};
    it('テストC', function () {
        console.log(`テストCの中 - ${value}`);
        value = 'CC';
    });
    console.log(`A-Cの後 - ${value}`);
});
console.log(`世界の中間 - ${value}`);
describe('実行順の確認D-E', function () {
    console.log(`D-Eの始まり - ${value}`);
    value = 'D';
    it('テストD', function () {
        console.log(`テストCの中 - ${value}`);
        value = 'DD';
    });
    console.log(`D-Eの中間 - ${value}`);
    value = 'E';
    it('テストE', function () {
        console.log(`テストDの中身 - ${value}`);
        value = 'EE';
    });
    console.log(`D-Eの後 - ${value}`);
});
console.log(`世界の終わり - ${value}`);

この実行結果は下記のようになる。

$ mocha test/test.js
世界の始まり - !!
A-Cの始まり - !!
A-Bの中間 - A
小世界 - B
B-Cの中間 - C
A-Cの後 - C
世界の中間 - C
D-Eの始まり - C
D-Eの中間 - D
D-Eの後 - E
世界の終わり - E


  実行順の確認A-C
テストAの中 - E
    √ テストA
テストBの中 - AA
    √ テストB
テストCの中 - BB
    √ テストC
    小世界
小世界の中 - CC
      √ 小世界のテスト

  実行順の確認D-E
テストCの中 - CC
    √ テストD
テストDの中身 - DD
    √ テストE


  6 passing (7ms)

コールバックのコンテキスト

describeitのコールバックが呼び出された時のコンテキスト(thisの指すもの)は、呼び出し元であるMochaが設定したものになる。ただしコールバックをアロー関数として定義すると、コンテキストは、コールバックが定義されたdescribeitのコンテキストとなり、Mochaの設定するコンテキストとはならない。

これは例えば下記のようなコードをitのコールバック中に書いた時に問題となる。

it('テストの説明', () => {
    this.timeout(3000);
    // ...
});

timeout()はMochaの提供する設定用の関数で、thisにMochaのコンテキストが設定されていないと呼び出せない。このためMochaのサイトでは、下記のように書かれている。

Passing arrow functions (aka “lambdas”) to Mocha is discouraged.

ただしdescribeitをクラスのメソッド内に書く場合は、コールバックをアロー関数として定義する。クラスのメソッドでは、そのオブジェクトがコンテキストとして必要だからである。この場合はtimeout()のような、Mochaの機能を呼び出すことはできない。

テストの打ち切り

複数のitがあり、そのうちの1つのitでAssertされた後、テストを打ち切りたい場合は、bailオプションを指定する。

mocha --bail

またはソースコードの中で直接設定する。

this.bail(true);

あるdescribeの中でitAssertされた時に、それ以降のitの実行を打ち切って、次のdescribeに実行を移したい場合は、下記のような書き方ができる。この場合、打ち切りではなく、スキップとして記録される。

describe(`テストケース1`, function () {
    let failed = false;
    beforeEach(function () { if (failed) { this.skip(); } })
    afterEach(function () { if (!failed) { failed = this.currentTest?.state === "failed"; } });

    it('ステップ1' function() {assert.fail('エラー');});
    it('ステップ2', ... );
}

describe(`テストケース2`, function () {
    let failed = false;
    beforeEach(function () { if (failed) { this.skip(); } })
    afterEach(function () { if (!failed) { failed = this.currentTest?.state === "failed"; } });

    it( ... );
    it( ... );
}

上記の例では、テストケース1のステップ1がエラーとなり、ステップ2はスキップされ、テストケース2は最初から実行される。

非同期処理

テストの中で非同期処理が必要な場合は、itのコールバックをasyncとして定義する。

describe('テスト', function() {
  it('内容', async function() {
    await doSomethingAsync();
  });
});

モジュールのインポートは要らない

Mochaを使ったテストコードには、Mochaをモジュールとしてインポートする必要はない。Mochaはテストコードを読み込んで起動する際に、Nodeのグローバル環境 (?) に、describeやitを読み込むので、そこから参照できる (らしい)。

ではTypeScriptはどのようにしてdescribeitの存在を知るのか?

TypeScriptは、モジュールのインポート先に型定義のファイルがあればそれを読み込む。モジュールのインポートが無い場合も、一定の規則にしたがって型定義を探しに行く。Mochaの型定義については、@types/mochaから型定義を読み込んでいる。

Chai

Mochaと一緒に使われるChaiについても簡単に …

Chaiとは何か

Chaiはテストの中で使われるAssertionライブラリ。

Chai is a BDD / TDD assertion library for node and the browser that can be delightfully paired with any javascript testing framework.

https://www.chaijs.com/

Assertionとは、何らかの条件をテストし、結果が偽(エラー)となる場合は、その結果を通知(Assert)するもの。Chaiの場合は、通知のために例外を送出する。(Mochaのitはこの例外をキャッチする。)

const result = doSomething();
assert.equal(result, "Well done", "Result was not good");

Assertされると、例外が送出されるので、テストコードの実行はそこで打ち切られる。itの中に記述した場合は、そのitブロックの実行が打ち切られ、その結果がitのテスト結果として記録される。

スタイル

ChaiはMochaと同じく、テストコードの中にテスト仕様を記述できるように意図されている。そのために幾つかのスタイルを用意している。

  • TDD向け
    • Assert
  • BDD向け
    • Should
    • Expect

ShouldExpectは、チェーン記法をサポートしているため、テスト条件をより自然言語に近い形で記述できる。

expect(foo).to.have.lengthOf(3);

その他

Mochaはテストドライバを書くためのフレームワークという理解だが、他のライブラリと組み合わせることで、テストスタブを追加したり、機能間の呼び出しをスパイしたりといった手法を適用できるらしい。

こちらはあちこちで見かける … SimonJS

終わりに

Mochaのフレームワークに従うテストコードの構造についての知見をまとめた。書かれたテストの実行順は、見た目の直感とは異なる部分がある。そこを認識していないと、思わぬところで足をすくわれるかも知れない。少なくとも筆者はそうだったし、それが備忘録を残す動機でもある。

もっとも筆者の場合は、独自に書いたテストコードを、後からMocha対応にしたことから、余計な混乱が生じた。Mochaの想定する構造とは異なる構造のなかにMochaを入れようとしたからで、結局全体をバッサリ書き直すことになった。

最初から、”Getting Started”に出てくるようなシンプルな形でテストコードを書けば、すんなり使えてしまうものなのかも知れない。

変更履歴

日付内容
2021/10/27テストスクリプトの起動コマンドの間違いを修正
npm start -> npm test
2021/06/10VS Codeでのデバックについて追記
2021/06/06打ち切りでbailがdescribe単位で有効としていた記述を削除 (全体に有効)
describeの中で途中でテストを打ち切る方法について追記
モジュールのインポートについて追記
2021/05/28再構成
2021/05/25コールバックに関する記述改良
2021/05/23初版

コメント

タイトルとURLをコピーしました