ユニットテストを記述する

このセクションでは、これまで作成したCLIアプリケーションにユニットテストを導入します。 ユニットテストの導入と合わせて、ソースコードを整理してテストがしやすくなるようにモジュール化します。

前のセクションまでは、すべての処理をひとつのJavaScriptファイルに記述していました。 ユニットテストを行うためにはテスト対象がモジュールとして分割されていなければいけません。 今回のアプリケーションでは、CLIアプリケーションとしてコマンドライン引数を処理する部分と、MarkdownをHTMLへ変換する部分に分割します。

CommonJSでのモジュール化

実際にアプリケーションのモジュール化をする前に、CommonJSでのモジュール化について簡単に振り返ります。

Node.jsでは、複数のJavaScriptファイル間で変数や関数などをやり取りするために、CommonJSモジュールという仕組みを利用します。 CommonJSモジュールからオブジェクトをエクスポートするには、Node.jsのグローバル変数であるmoduleオブジェクトを利用します。 module.exportsオブジェクトは、そのファイルからエクスポートされるオブジェクトを格納します。

次のgreet.jsというファイルは、greet関数をエクスポートするモジュールの例です。

greet.js

// greet.js
module.exports = function greet(name) {
    return `Hello ${name}!`;
};

require関数を使って、指定したファイルパスのJavaScriptファイルをモジュールとしてインポートできます。 次のコードでは先ほどのgreet.jsのパスを指定してモジュールとしてインポートして、エクスポートされた関数を取得しています。

greet-main.js

const greet = require("./greet");
greet("World"); // => "Hello World!"

module.exportsオブジェクトそのものに代入するのではなく、module.exportsオブジェクトのプロパティに代入することでも任意の値をエクスポートできます。 次のfunctions.jsというファイルでは、foobarの2つの関数を同じファイルからエクスポートしています。

functions.js

module.exports.foo = function() {
    console.log("foo関数が呼び出されました");
};
module.exports.bar = function() {
    console.log("bar関数が呼び出されました");
};

このようにエクスポートされたオブジェクトは、require関数の返り値であるオブジェクトのプロパティとしてアクセスできます。 次のコードでは先ほどのfunctions.jsをインポートして取得したオブジェクトからfoobar関数をプロパティとして取得しています。

functions-main.js

const functions = require("./functions");
functions.foo();
functions.bar();

アプリケーションをモジュールに分割する

それではCLIアプリケーションのソースコードをモジュールに分割してみましょう。 md2html.jsという名前のJavaScriptファイルを作成し、次のようにmarkedを使ったMarkdownの変換処理を記述します。

md2html.js

const marked = require("marked");

module.exports = (markdown, cliOptions) => {
    return marked(markdown, {
        gfm: cliOptions.gfm,
    });
};

このモジュールがエクスポートするのは、与えられたオプションを元にMarkdown文字列をHTMLに変換する関数です。 アプリケーションのエントリーポイントであるmain.jsでは、次のようにこのモジュールをインポートして使用します。

main.js

const program = require("commander");
const fs = require("fs");
// md2htmlモジュールをインポートする
const md2html = require("./md2html");

program.option("--gfm", "GFMを有効にする");
program.parse(process.argv);
const filePath = program.args[0];

const cliOptions = {
    gfm: false,
    ...program.opts(),
};

fs.readFile(filePath, { encoding: "utf8" }, (err, file) => {
    if (err) {
        console.error(err);
        process.exit(1);
        return;
    }
    // md2htmlモジュールを使ってHTMLに変換する
    const html = md2html(file, cliOptions);
    console.log(html);
});

markedパッケージや、そのオプションに関する記述がひとつのmd2html関数に隠蔽され、main.jsがシンプルになりました。 そしてmd2html.jsはアプリケーションから独立したひとつのモジュールとして切り出され、ユニットテストが可能になりました。

ユニットテスト実行環境を作る

ユニットテストの実行にはさまざまな方法があります。 このセクションではテスティングフレームワークとしてMochaを使って、ユニットテストの実行環境を作成します。 Mochaが提供するテスト実行環境では、グローバルにitdescribeなどの関数が定義されます。 it関数はその内部でエラーが発生したとき、そのテストを失敗として扱います。 つまり、期待する結果と異なるならエラーを投げ、期待どおりならエラーを投げないというテストコードを書くことになります。

今回はNode.jsの標準モジュールのひとつであるassertモジュールから提供されるassert.strictEqualメソッドを利用します。 assert.strictEqualメソッドは第一引数と第二引数の評価結果が===で比較して異なる場合に、例外を投げる関数です。

Mochaによるテスト環境を作るために、まずは次のコマンドでmochaパッケージをインストールします。

$ npm install --save-dev mocha@7

--save-devオプションは、パッケージをdevDependenciesとしてインストールするためのものです。 package.jsondevDependenciesには、そのパッケージを開発するときだけ必要な依存ライブラリを記述します。

ユニットテストを実行するには、Mochaが提供するmochaコマンドを使います。 Mochaをインストールした後、package.jsonscriptsプロパティを次のように記述します。

{
    ...
    "scripts": {
        "test": "mocha test/"
    },
    ...
}

この記述により、npm testコマンドを実行すると、mochaコマンドでtest/ディレクトリにあるテストファイルを実行します。 試しにnpm testコマンドを実行し、Mochaによるテストが行われることを確認しましょう。 まだテストファイルを作っていないので、Error: No test files foundというエラーが表示されます。

$ npm test
> mocha

 Error: No test files found

ユニットテストを記述する

テストの実行環境ができたので、実際にユニットテストを記述します。 Mochaのユニットテストはtestディレクトリの中にJavaScriptファイルを配置して記述します。 test/md2html-test.jsファイルを作成し、md2html.jsに対するユニットテストを次のように記述します。

const assert = require("assert");
const fs = require("fs");
const path = require("path");
const md2html = require("../md2html");

it("converts Markdown to HTML (GFM=false)", () => {
    // fs.readFileSyncは同期的にファイルを読み込むメソッド
    const sample = fs.readFileSync(path.resolve(__dirname, "./fixtures/sample.md"), { encoding: "utf8" });
    const expected = fs.readFileSync(path.resolve(__dirname, "./fixtures/expected.html"), { encoding: "utf8" });
    // 末尾の改行の有無の違いを無視するため、変換後のHTMLのスペースをtrimメソッドで削除してから比較しています
    assert.strictEqual(md2html(sample, { gfm: false }).trimEnd(), expected.trimEnd());
});

it("converts Markdown to HTML (GFM=true)", () => {
    const sample = fs.readFileSync(path.resolve(__dirname, "./fixtures/sample.md"), { encoding: "utf8" });
    const expected = fs.readFileSync(path.resolve(__dirname, "./fixtures/expected-gfm.html"), { encoding: "utf8" });
    // 末尾の改行の有無の違いを無視するため、変換後のHTMLのスペースをtrimメソッドで削除してから比較しています
    assert.strictEqual(md2html(sample, { gfm: true }).trimEnd(), expected.trimEnd());
});

it関数で定義したユニットテストは、md2html関数の変換結果が期待するものになっているかをテストしています。 test/fixturesディレクトリにはユニットテストで用いるファイルを配置しています。 今回は変換元のMarkdownファイルと、期待する変換結果のHTMLファイルが存在します。

次のように変換元のMarkdownファイルをtest/fixtures/sample.mdに配置します。

test/fixtures/sample.md

# サンプルファイル

これはサンプルです。
https://jsprimer.net/

- サンプル1
- サンプル2

そして、期待する変換結果のHTMLファイルもtest/fixturesディレクトリに配置します。 gfmオプションの有無にあわせて、expected.htmlexpected-gfm.htmlの2つを次のように作成しましょう。

test/fixtures/expected.html

<h1 id="サンプルファイル">サンプルファイル</h1>
<p>これはサンプルです。
https://jsprimer.net/</p>
<ul>
<li>サンプル1</li>
<li>サンプル2</li>
</ul>

test/fixtures/expected-gfm.html

<h1 id="サンプルファイル">サンプルファイル</h1>
<p>これはサンプルです。
<a href="https://jsprimer.net/">https://jsprimer.net/</a></p>
<ul>
<li>サンプル1</li>
<li>サンプル2</li>
</ul>

ユニットテストの準備ができたら、もう一度改めてnpm testコマンドを実行しましょう。2件のテストが通れば成功です。

$ npm test
> mocha

  ✓ converts Markdown to HTML (GFM=false)
  ✓ converts Markdown to HTML (GFM=true)

  2 passing (31ms)

ユニットテストが通らなかった場合は、次のことを確認してみましょう。

  • test/fixturesディレクトリにsample.mdexpected.htmlexpected-gfm.htmlというファイルを作成したか
  • それぞれのファイルは文字コードがUTF-8で、改行コードがLFになっているか
  • それぞれのファイルの末尾に余計な改行文字が入っていないか

なぜユニットテストを行うのか

ユニットテストを実施することには多くの利点があります。 早期にバグが発見できることや、安心してリファクタリングを行えるようになるのはもちろんですが、 ユニットテストが可能な状態を保つこと自体に意味があります。 実際にテストを行わなくてもテストしやすいコードになるよう心がけることが、アプリケーションを適切にモジュール化する指針になります。

またユニットテストには生きたドキュメントとしての側面もあります。 ドキュメントはこまめにメンテナンスされないとすぐに実際のコードと齟齬が生まれてしまいますが、 ユニットテストはそのモジュールが満たすべき仕様を表すドキュメントとして機能します。

ユニットテストの記述は手間がかかるだけのようにも思えますが、 中長期的にアプリケーションをメンテナンスする場合にはかかせないものです。 そしてよいテストを書くためには、日頃からテストを書く習慣をつけておくことが重要です。

まとめ

このユースケースの目標であるNode.jsを使ったCLIアプリケーションの作成と、ユニットテストの導入ができました。 npmを使ったパッケージ管理や外部モジュールの利用、fsモジュールを使ったファイル操作など、多くの要素が登場しました。 これらはNode.jsアプリケーション開発においてほとんどのユースケースで応用されるものなので、よく理解しておきましょう。

このセクションのチェックリスト

  • Markdownの変換処理をCommonJSモジュールとしてmd2html.jsに切り出し、main.jsから読み込んだ
  • mochaパッケージをインストールし、npm testコマンドでmochaコマンドを実行できることを確認した
  • md2html関数のユニットテストを作成し、テストの実行結果を確認した