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

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

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

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

実際にアプリケーションのモジュール化をする前に、ECMAScriptモジュールにおけるエクスポートについて簡単に振り返ります。

ECMAScriptモジュールではexport文を使って変数や関数などのオブジェクトをエクスポートし、他のスクリプトから利用できるようにします。 次のgreet.jsというファイルは、greet関数をエクスポートするモジュールの例です。

greet.js

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

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

greet-main.js

import { greet } from "./greet.js";
greet("World"); // => "Hello World!"

これから行うアプリケーションのモジュール化とは、このようにアプリケーションの一部分を別のファイルに切り出した上で、必要なオブジェクトをエクスポートして外部から利用可能にするということです。 機能をモジュールとして切り出すことで、アプリケーションとユニットテストの両方から利用できるようになります。

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

md2html.js

import { marked } from "marked";

export function md2html(markdown, cliOptions) {
    return marked.parse(markdown, {
        gfm: cliOptions.gfm,
    });
};

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

main.js

import * as util from "node:util";
import * as fs from "node:fs/promises";
// md2htmlモジュールからmd2html関数をインポートする
import { md2html } from "./md2html.js";

// コマンドライン引数からファイルパスとオプション/フラグを受け取る
const {
    values,
    positionals
} = util.parseArgs({
    allowPositionals: true,
    options: {
        // gfmフラグを定義する
        gfm: {
            type: "boolean",
            default: false,
        }
    }
});
const filePath = positionals[0];
fs.readFile(filePath, { encoding: "utf8" }).then(file => {
    // md2htmlモジュールを使ってHTMLに変換する
    const html = md2html(file, {
        // gfmフラグのパース結果をオプションとして渡す
        gfm: values.gfm
    });
    console.log(html);
}).catch(err => {
    console.error(err.message);
    process.exit(1);
});

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

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

ユニットテストの実行にはさまざまな方法があります。 このセクションではNode.jsの標準モジュールのひとつであるtestモジュールから提供されるtestを使って、ユニットテストの実行環境を作成します。 test関数はその内部でエラーが発生したとき、そのテストを失敗として扱います。 つまり、期待する結果と異なるならエラーを投げ、期待どおりならエラーを投げないというテストコードを書くことになります。

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

ユニットテストを実行するには、Node.jsが提供するnodeコマンドの--testオプションを使います。 package.jsonscriptsプロパティには、次のように記述します。

{
    ...
    "scripts": {
        "test": "node --test"
    },
    ...
}

この記述により、npm testコマンドを実行すると、node --testtest/ディレクトリにあるテストファイルを実行します。実行時にディレクトリの指定を省いていますが、node --testはデフォルトでtest/ディレクトリを探索するようになってます。 試しにnpm testコマンドを実行し、テストが行われることを確認しましょう。 まだテストファイルを作っていないので、0個のテストが実行されて正常終了します。

$ npm test

> test
> node --test

ℹ tests 0
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7.3243

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

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

test関数は第一引数にテストのタイトルを入れ、第二引数にテストの内容を書きます。

test/md2html-test.js

import { test } from "node:test";
import * as assert from "node:assert";
import * as fs from "node:fs/promises";
import { md2html } from "../md2html.js";

test("converts Markdown to HTML (GFM=false)", async() => {
    // fs.readFileはPromiseを返すので、`await`式で読み込みが完了するまで待って内容を取得する
    const sample = await fs.readFile("test/fixtures/sample.md", {
        encoding: "utf8",
    });
    const expected = await fs.readFile("test/fixtures/expected.html", {
        encoding: "utf8",
    });
    // 末尾の改行の有無の違いを無視するため、変換後のHTMLのスペースをtrimメソッドで削除してから比較しています
    assert.strictEqual(
        md2html(sample, { gfm: false }).trimEnd(),
        expected.trimEnd(),
    );
});

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

test関数で定義したユニットテストは、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

> test
> node --test

✔ converts Markdown to HTML (GFM=false) (12.2419ms)
✔ converts Markdown to HTML (GFM=true) (4.4282ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63.881902

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

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

たとえば、npm testを実行して次のようにテストが失敗している場合のエラーメッセージを見てみましょう。

$ npm test

> test
> node --test

✔ converts Markdown to HTML (GFM=false) (11.568601ms)
✖ converts Markdown to HTML (GFM=true) (5.6456ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected ... Lines skipped

    '<h1 id="サンプルファイル">サンプルファイル</h1>\n' +
      '<p>これはサンプルです。\n' +
  ...
      '<li>サンプル1</li>\n' +
      '<li>サンプル2</li>\n' +
  +   '</ul>'
  -   '</ul>\n' +
  -   ';;;'
      at TestContext.<anonymous> (file:///Users/laco/nodecli/test/md2html-test.js:29:10)
      at async Test.run (node:internal/test_runner/test:632:9)
      at async Test.processPendingSubtests (node:internal/test_runner/test:374:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: '<h1 id="サンプルファイル">サンプルファイル</h1>\n<p>これはサンプルです。\n<a href="https://jsprimer.net/">https://jsprimer.net/</a></p>\n<ul>\n<li>サンプル1</li>\n<li>サンプル2</li>\n</ul>',
    expected: '<h1 id="サンプルファイル">サンプルファイル</h1>\n<p>これはサンプルです。\n<a href="https://jsprimer.net/">https://jsprimer.net/</a></p>\n<ul>\n<li>サンプル1</li>\n<li>サンプル2</li>\n</ul>\n;;;',
    operator: 'strictEqual'
  }

ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 64.548503

✖ failing tests:

test at file:/Users/laco/nodecli/test/md2html-test.js:21:1
✖ converts Markdown to HTML (GFM=true) (5.6456ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected ... Lines skipped

    '<h1 id="サンプルファイル">サンプルファイル</h1>\n' +
      '<p>これはサンプルです。\n' +
  ...
      '<li>サンプル1</li>\n' +
      '<li>サンプル2</li>\n' +
  +   '</ul>'
  -   '</ul>\n' +
  -   ';;;'
      at TestContext.<anonymous> (file:///Users/laco/nodecli/test/md2html-test.js:29:10)
      at async Test.run (node:internal/test_runner/test:632:9)
      at async Test.processPendingSubtests (node:internal/test_runner/test:374:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: '<h1 id="サンプルファイル">サンプルファイル</h1>\n<p>これはサンプルです。\n<a href="https://jsprimer.net/">https://jsprimer.net/</a></p>\n<ul>\n<li>サンプル1</li>\n<li>サンプル2</li>\n</ul>',
    expected: '<h1 id="サンプルファイル">サンプルファイル</h1>\n<p>これはサンプルです。\n<a href="https://jsprimer.net/">https://jsprimer.net/</a></p>\n<ul>\n<li>サンプル1</li>\n<li>サンプル2</li>\n</ul>\n;;;',
    operator: 'strictEqual'
  }

このテスト結果では converts Markdown to HTML (GFM=true) というタイトルのテストが1つ失敗していることがわかります。 また、+ actual - expectedには、assert.strictEqualで比較した結果が一致していない部分が表示されています。 この場合は、expected(期待する結果)の末尾に;;;という不要な文字列が入ってしまっているのが、テストが失敗している理由です。 そのため、expected-gfm.htmlファイルを確認し不要な;;;という文字列を取り除けば、テストが通るようになるはずです。

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

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

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

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

まとめ

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

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

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