備忘録: 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はそのテストコードをMochaに登録する。Mochaは後述する実行順序でテストコードを呼び出す。

テストコードの中ではテストを実行しその結果を検証し、エラーがあれば例外を送出する。Mochaはその例外をキャッチして、テスト結果としてレポートする。次に記述するdescribeと違い、ネストすることはできない。

describe

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

コールバックの実行順序

describeitの本体は、コールバックとして記述されることに注意する。つまりdescribeitの呼び出しとコールバックの呼び出しは異なるタイミングとなる。

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

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

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

itのコールバックが呼び出される時、そのコンテキストはMochaが設定したものとなる。そのコンテキストには、describeitなどのMocha自身の機能が予めロードされている。(初めてMochaのプログラム例を見た時、describeitがどこから来たのか不思議だったが …)

describeのコールバックに、itの呼び出し以外の何らかのコードを書くことは禁止されていない。ただし、その結果がどのようにテストコード (itのコールバック) に反映されるのかは要注意である。

コールバックの実行順序とコンテキストを確認するために、下記のようなプログラムを書いてみた。途中変数valueに値を入れ直しながら、コールバックの中で参照している。

console.log(`世界の始まり`);
let value = '!!';

describe('世界#1', function () {
    console.log(`> 世界#1の始まり - ${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 = 'SS';
        });

        console.log(`>> 小世界の終わり - ${value}`);
    })

    console.log(`> B-Cの中間 - ${value}`);
    value = 'C'

    it('テストC', function () {
        console.log(`テストCの中 - ${value}`);
        value = 'CC';
    });

    console.log(`> 世界#1の終わり - ${value}`);
});

console.log(`世界の中間 - ${value}`);

describe('世界#2', function () {
    console.log(`> 世界#2の始まり - ${value}`);
    value = 'D';

    it('テストD', function () {
        console.log(`テストDの中 - ${value}`);
        value = 'DD';
    });

    console.log(`> D-Eの中間 - ${value}`);
    value = 'E';

    it('テストE', function () {
        console.log(`テストEの中身 - ${value}`);
        value = 'EE';
    });

    console.log(`> 世界#2の終わり - ${value}`);
});

console.log(`世界の終わり - ${value}`);

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

世界の始まり
> 世界#1の始まり - !!
> A-Bの中間 - A
>> 小世界の始まり - B
>> 小世界の終わり - B
> B-Cの中間 - B
> 世界#1の終わり - C
世界の中間 - C
> 世界#2の始まり - C
> D-Eの中間 - D
> 世界#2の終わり - E
世界の終わり - E


  世界#1
テストAの中 - E
    ✔ テストA
テストBの中 - AA
    ✔ テストB
テストCの中 - BB
    ✔ テストC
    小世界
小世界の中 - CC
      ✔ 小世界のテスト

  世界#2
テストDの中 - SS
    ✔ テストD
テストEの中身 - DD
    ✔ テストE


  6 passing (2ms)

describeitでコールバックの実行タイミングが異なることが分かる。また各itの中で参照している変数valueの値は、直前に外側のdescribeの中で定義されたものとは異なっている。(グローバル変数を参照しているので、valueの更新はコールバックの実行順に更新・引継されることになる。)

前処理と後処理のためのHook

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

before/afterdescribeでまとめられたテスト全体の前処理、後処理であり、beforeEach/afterEachは、各テスト対する前処理、後処理となる。

describe('Before/After', function () {
    before(function () {
        console.log('> Before');
    });
    beforeEach(function () {
        console.log('> BeforeEach');
    });
    after(function () {
        console.log('> After');
    });
    afterEach(function () {
        console.log('> AfterEach');
    });

    describe('Before/After - Sub', function () {
        it('Test - Sub', function () {
            console.log('>> Test - Sub');
        });
    });

    it('Test#1', function () {
        console.log('> Test#1');
    });

    it('Test#2', function () {
        console.log('> Test #2');
    })
});

この実行結果を下記に示す。

  Before/After
> Before
> BeforeEach
> Test#1
    ✔ Test#1
> AfterEach
> BeforeEach
> Test #2
    ✔ Test#2
> AfterEach
    Before/After - Sub
> BeforeEach
>> Test - Sub
      ✔ Test - Sub
> AfterEach
> After


  3 passing (3ms)

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

アロー関数の利用は推奨されない

itのコールバックは、functionを使って定義する。一般的にはfunctionの代わりにアロー関数を使っても同じ結果が得られる。しかしitの場合は結果が異なる。Mochaがitを呼び出す時には、独自のコンテキストを設定して呼び出すからである。しかしコールバックをアロー関数として定義した場合、コンテキストは、コールバックが定義されたitのコンテキストとなり、Mochaの設定するコンテキストとはならない。

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

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

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

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

https://mochajs.org/#arrow-functions

テストの打ち切り

複数の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はテストコードを読み込んで起動する際に、事前に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 assert = require('chai').assert;

describe('Test with Chai', function () {
    it('Test', function () {
        // Do something ...
        const result = false;
        // ...
        assert.equal(result, true, 'asserted');
    });
});
  Test with Chai
    1) Test


  0 passing (2ms)
  1 failing

  1) Test with Chai
       Test:

      asserted
      + expected - actual

      -false
      +true
      
      at Context.<anonymous> (test/test-with-chai.js:6:16)
      at process.processImmediate (node:internal/timers:476:21)

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”に出てくるようなシンプルな形でテストコードを書けば、すんなり使えてしまうものなのかも知れない。

変更履歴

日付内容
2023/09/12コールバックとコンテキストに関する説明を整理・加筆
2023/09/10describeとitの説明を見直し
2023/09/08実行サンプルのシンタックスエラーとラベル間違いを修正
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初版