JavaScriptのユニットテストを始めよう - ユニットテストのメリットと書き方のコツ

51

プログラミングにおいて、ユニットテストを書いてコードが正しく動くか検証することはとても重要です。ユニットテストを導入する目的といえば品質の向上ですが、それ以外にもメリットがたくさんあります。

この記事ではユニットテストを書くとどんなメリットがあるのか、またユニットテストを書くときのちょっとしたコツを紹介します。

ユニットテストを書くメリット

「TODOアプリ」を作っていると仮定して、実際にコードとテストコードを確認しながらメリットを考えてみましょう。

この記事で紹介するテストコードは以下から確認できます。実際に手元で動かせるのでぜひ試してみてください。

※上記のテストコードはテストフレームワークのVitestを使用して書かれていますが、記事の内容自体はフレームワークによらない普遍的な考え方をもとにしています。

メリット①書いたコードが意図したとおりに動くかすぐ確認できる

コードを書いた際にそれが正しく動いているか? を確認するためにはコードを実際に動かす必要があります。以下はタスクを追加する関数です。

/**
 * TODOリストに新しいTODOを追加する
 * @param {Array} todos 既存のTodoリスト
 * @param {string} title TODOのタイトル
 * @param {string} dueDate TODOの期限
 * @param {boolean} completed TODOが完了済みかどうか。指定しない場合はfalse。
 * @returns {Array} 新しいTodoを追加したTodoリスト
 */
export const addTodo = (todos, title, dueDate, completed = false) => {
  const newTodo = { title, dueDate, completed };
  return [...todos, newTodo];
};

実際にこのコードを動かして結果を得るにはTODOアプリをブラウザ上で操作する必要があります。もしまだ画面UIができていなければ画面上で動かして確認することはできません。しかし、ユニットテストがあれば、テストを実行するだけで結果を確認できます。

テストの結果

メリット②ドキュメントとしての役割が期待できる

コードリーディングは大事なスキルですが、たいていの場合コードだけで何が行われているかを理解するのは難しく時間がかかります。

以下はTODOリストを期限の昇順で並び替える関数とそのテストコードです。簡単なコードではありますが、sortByDueDateAsc()関数だけを読むよりも、テストコードを合わせて読んだ方がわかりやすいのではないでしょうか?

/**
 * TODOリストを期限の昇順でソートする
 * @param {Array} todos TODOリスト
 * @returns {Array} ソート済みのTODOリスト
 */
export const sortByDueDateAsc = (todos) => {
  return [...todos].sort((a, b) => a.dueDate.localeCompare(b.dueDate, "ja-JP"));
};
describe("sortByDueDateAsc", () => {
  test("期限の昇順でソートされること", () => {
    // TODOリスト
    const todos = [
      { title: "粗大ゴミを捨てる", dueDate: "2024-08-10", completed: false },
      { title: "本を読み終わる", dueDate: "2024-08-01", completed: false },
      { title: "書類を出す", dueDate: "2024-08-20", completed: false },
    ];

    // ソートする
    const sorted = sortByDueDateAsc(todos);

    // 期限日の昇順でソートされている
    expect(sorted).toEqual([
      { title: "本を読み終わる", dueDate: "2024-08-01", completed: false },
      { title: "粗大ゴミを捨てる", dueDate: "2024-08-10", completed: false },
      { title: "書類を出す", dueDate: "2024-08-20", completed: false },
    ]);
  });
});

テストコードには、どのような条件で、期待する結果は何か が端的に表現されています。JSDocでも関数の説明は書かれていますが、コードを実際に動かした時の条件と結果が表現できるのは大きなメリットです。

メリット③コードレビュー時の負担軽減

先ほどのsortByDueDateAsc()関数をコードレビューする時のことを考えてみましょう。もしテストコードがなければ、関数のコードだけで機能が正しく実装されているかを判断する必要があります。

  • 昇順(「8月1日」の次に「8月2日」が来るよう)に並び替えられるか? 逆になっていないか?
  • localeCompare()で実現できるのか?

こんな疑問が頭に浮かぶかもしれません。そんな時はテストコードを確認し、ユニットテスト動かせば期待通りに実装されているのがすぐわかります。

このようにテストコードがあるとレビューアーの負担は軽減します。また、テストケース自体をレビューすることでバグの早期発見につながる可能性もあります。

メリット④理解のためのテスト

メリット②や③でも書いた通り、テストは他人が書いたコードを理解するのに役立ちます。ユニットテストを実行できる環境であれば、コードを読みながらテストケースを自分で追加してみて理解を深めるという使い方もできます。

たとえばsortByDueDateAsc()関数を理解するために、「今のテストコードでは2024年8月のTODOしかないから、違う月である2024年9月のものを追加したらどうなるか」や、「違う年である2023年のTODOを加えたらどうなるか」を手元でテストケースに追加して動かすといった使い方です。

実務の中ではもっと複雑な処理を読み解く場合もあります。コードをただ読むだけより、この場合はどうなる? を手元で動かす方が理解しやすいのではないでしょうか。

メリット⑤手作業では難しいテストが簡単にできる

新しいTODOを追加するaddTodo()メソッドに、TODOを100個以上追加しようとするとエラー文言を出す仕様を加えてみましょう。これをブラウザ上で動かして動作の確認を行う場合、まずTODOを99個追加しなければなりません。

しかし、ユニットテストがあれば、発生条件が難しかったり、手作業では時間がかかる場合でも簡単にテストできます。

describe("addTodo", () => {
  test("既存のTODOが99個あった場合にエラーになること", () => {
    // 既存のTODOリスト(99個)
    const todos = Array.from({ length: 99 }, (_, i) => ({
      title: `TODO${i + 1}`,
      dueDate: "2024-08-10",
      completed: false,
    }));

    // 新しいTODOを追加
    const result = addTodo(todos, "TODO100", "2024-08-10");

    // エラーメッセージが返ることを確認
    expect(result).toBe("これ以上TODOを追加できません。");
  });
});

メリット⑥コード変更時のバグ予防

ユニットテストはバグ予防にも効果的です。

リファクタリングや機能の変更、修正でコードを変更した際、それが思わぬところに影響することがあります。また、本来すべきでない変更をしてしまうこともあります。

アプリ全体にユニットテストが書かれていればテストが失敗することで間違いに気づけます。このように思わぬバグを予防できるのもメリットの1つです。

ユニットテストを書くコツ

ここまでユニットテストを書くメリットをお伝えしました。ここからはすぐに使えるユニットテストを書くコツをご紹介します。

メリットのところでも説明した通り、ユニットテストは動作や期待値を他の人にわかりやすく伝えるために書くという側面があります。そのため、テストコード自体をわかりやすく書くことも重要です。

javascript-testing-best-practices(GitHub)』(日本語訳あり)では、JavaScriptとNode.jsのテストのベストプラクティスを紹介しています。とても参考になる内容ですが、かなりボリュームがあるためこの中からすぐに使えるものを3つ紹介します。もし気になった方はもとのページもぜひ読んでみてください。

コツ①テスト名は「何が・どんな条件で・どうなる」を含める

テスト名には以下の3点を含めるようにしましょう。

  • 何が:何をテストしているのか(メソッド名など)
  • どんな条件で:想定している条件やシナリオは何か
  • どうなる:どんな結果を期待しているか
// ❌よくない例
describe("test", () => {
  test("追加できる", () => {
    const todos = [];
    const updated = addTodo(todos, "TODO1", "2024-08-10");
    expect(updated).toEqual([
      { title: "TODO1", dueDate: "2024-08-10", completed: false },
    ]);
  })
})

// ⭕️よい例
// 何が:addTodo
// どんな条件で:既存のTODOが空
// どうなる:新しいTODOを追加できること
describe("addTodo", () => {
  test("既存のTODOが空でも新しいTODOを追加できること", () => {
    const todos = [];
    const updated = addTodo(todos, "TODO1", "2024-08-10");
    expect(updated).toEqual([
      { title: "TODO1", dueDate: "2024-08-10", completed: false },
    ]);
  });
})

コツ②テストコードを構造化して結果を見やすくする

ユニットテストのフレームワークではテストスイート(同じような目的のテストをひとまとまりにしたもの)を定義できます。Vitestではdescribeがそれに該当します。テストケースをまとめて構造化すると結果が見やすくなるので積極的にまとめていきましょう。『javascript-testing-best-practices』では少なくとも2階層に分けてまとめることをすすめています。

// ❌よくない例
describe("sortTodos", () => {
  test("期限の昇順でソートされること", () => /** 省略 */);
  test("期限の降順でソートされること", () => /** 省略 */);
  test("うるう年でも正しくソートされること", () => /** 省略 */);
  test("同じ期限の時はid順でソートされること", () => /** 省略 */);
  test("タイトルの昇順でソートされること", () => /** 省略 */);
  test("タイトルの降順でソートされること", () => /** 省略 */);
  test("同じタイトルの時はid順でソートされること", () => /** 省略 */);
})

// ⭕️よい例
// 「期限でのソート」と「タイトルでのソート」をdescribeで分けている
describe("sortTodos", () => {
  describe("期限でのソート", () => {
    test("期限の昇順でソートされること", () => /** 省略 */);
    test("期限の降順でソートされること", () => /** 省略 */);
    test("うるう年でも正しくソートされること", () => /** 省略 */);
    test("同じ期限の時はid順でソートされること", () => /** 省略 */);
  });

  describe("タイトルでのソート", () => {
    test("タイトルの昇順でソートされること", () => /** 省略 */);
    test("タイトルの降順でソートされること", () => /** 省略 */);
    test("同じタイトルの時はid順でソートされること", () => /** 省略 */);
  })
});

テストスイートを構造化したものとしないものの比較

コツ③AAAパターンでわかりやすく

ユニットテストの内容をArrange(準備)、Act(動かす)、Assert(確認)のブロックで分けて書くと流れがわかりやすくなります。テストコード自体の見やすさが向上し、ユニットテスト全体の書き方も統一されます。

// ❌よくない例
// テストコードがまとまっていて見づらい
describe("addTodo", () => {
  test("新しいTODOを追加できること", () => {
    const todos = [{ title: "TODO1", dueDate: "2024-08-10", completed: false }];
    const updated = addTodo(todos, "TODO2", "2024-08-20");
    expect(updated).toEqual([
      { title: "TODO1", dueDate: "2024-08-10", completed: false },
      { title: "TODO2", dueDate: "2024-08-20", completed: false },
    ]);
  });
});

// ⭕️よい例
// AAAパターンで分けて書かれている
describe("addTodo", () => {
  test("新しいTODOを追加できること", () => {
    // Arrange(準備)
    // 既存のTODO
    const todos = [{ title: "TODO1", dueDate: "2024-08-10", completed: false }];

    // Act(動かす)
    // 新しいTODOを追加
    const updated = addTodo(todos, "TODO2", "2024-08-20");

    // Assert(確認)
    // 新しいTODOが追加されていることを確認
    expect(updated).toEqual([
      { title: "TODO1", dueDate: "2024-08-10", completed: false },
      { title: "TODO2", dueDate: "2024-08-20", completed: false },
    ]);
  });
});

まとめ

ユニットテストを書くメリット、わかりやすく書くコツを紹介しました。ユニットテストは品質向上だけでなく他にもメリットがたくさんあるので、筆者個人的にはテストコードを書いたりテストフレームワークを導入する手間をかけてでも活用する価値があると思っています。

今までユニットテストを書いたことがなかった方や、なんとなく書いていたといった方に参考になれば嬉しいです。

北川 杏子

アパレル、事務を経てエンジニアに転身。フルスタックエンジニアとしてバックエンド、フロントエンド両方の開発を経験したのちICSに入社。特技は英語。

この担当の記事一覧