脱初心者のためのテストの書き方 テストを書く手順

自分が書いたコードに自信がないとき、確かめる一つの手段に「テストを書くこと」があります。テストライブラリなど、便利なツールもありますが、そもそもテストとは何か、何をテストするのか、というところから始めてみましょう。

発行

  • 杉浦 有右嗣
  • 渡辺 由
  • 丸山 陽子
脱初心者のためのテストの書き方 シリーズの記事一覧

自分が書いたコードに自信がない

グリスケ:すみません、ちょっとコードを見ていただきたいんですが……。

先生:おや、あなたは、先月中途採用で入社したグリスケさんですね。確か、グリオ君の学生時代の先輩じゃなかったでしたっけ?

グリオ君ってだれ?

グリオ君は、入門したてのエンジニア見習い。ミニゲームを作ってみたりChatGPTにコードを書かせたり(そして先生に諭される)、いろいろチャレンジ中。グリスケさんは、グリオ君よりはもう少しコードが書けるようです。

グリスケ:あ、そうなんです、入社したらグリオ君がいてびっくりしました(笑)。

僕は前職では、職場にフロントエンド・エンジニアがほとんどいなくて、わりと自己流でコードを書いていたってところがあるんですよね。レビューしてもらう環境もなかったんです。だから、自分のコードに自信がなくて。

先生:なるほど、じゃあ、ちょっと見てみましょうか。

「ページャー」を実装してみたけれど

グリスケ:ページャーコンポーネントの実装なんです。こんなふうに表示される予定です。左右に「前のページ」「次のページ」っていうボタンがあって、10ページ単位くらいで、前後にページ送りをするっていう、よくあるナビゲーションですね。

先生:ふむ。このページャーの要件はどうなっているんですか?

グリスケ:え?(今説明したよね?)

要件……というほどではないんですが、現在、ページ内に表示されている項目のページ範囲を表示して、トータルのページ数がわかって、前のページと次のページに移動できるようになっているというような、よくあるページャーです。

先生:なるほど。「よくあるページャー」なんですね。じゃあ、コードを説明してもらえますか?

グリスケ:はい、Reactで書いてみました。

グリスケが書いたページャーコンポーネント

export default function App() {
  // ...
  return (
    <div className="App">
      // Pagerコンポーネントの呼び出し
      <Pager
        total={10} // 合計のアイテム数
        perPage={10} // 1ページに表示するアイテム数
        currentPage={1} // 現在のページ
        onChangePage={(page) => console.log(`Change page: ${page}`)} // ページ変更時のコールバック
      />
    </div>
  );
}

// Pagerコンポーネントの中身
const Pager = ({ total, perPage, currentPage, onChangePage }) => {
  const from = (currentPage - 1) * perPage + 1;
  const to = currentPage * perPage > total ? total : currentPage * perPage;
  const lastPage = Math.ceil(total / perPage);

  return (
    <div className="pager">
      <button
        disabled={currentPage <= 1}
        onClick={() => onChangePage(currentPage - 1)}
      >
        前のページ
      </button>
      <span>{`${from} - ${to} / ${total}`}</span>
      <button
        disabled={currentPage >= lastPage}
        onClick={() => onChangePage(currentPage + 1)}
      >
        次のページ
      </button>
    </div>
  );
};

まずPagerコンポーネントを呼び出すメインの部分です。全ページ数totalと、1ページあたりに表示するページ数perPage、現在表示中のページcurrentPageを受け取って、ページャーを表示します。onChangePageは、ページが変わったときに呼ばれるコールバック関数です。

で、次はPagerコンポーネントの中身です。totalperPagecurrentPageonChangePageを受け取って、表示中の最初のアイテム番号のfrom、表示中の最後のアイテム番号のto、「次のページ」ボタンが押せるかどうかを判定するのに必要な最後のページ番号lastPageを計算します。

表示では、「前のページ」ボタンはdisabled={currentPage <= 1}にしているので、現在のページが1ページ目のときは押せなくなります。そうでなければ、onChangePage(currentPage - 1)で前のページに移動します。「次のページ」はその逆です。

先生:そうですね。さて、このコードでいいのか不安、ということですよね。自信がないなら、テストを書きましょう

グリスケ:え、コンポーネントのテストですか……。

先生:いえいえ、ここはコンポーネントが問題ではないですよ。

ロジックを分離する

先生:このコードで、ロジックの部分はどこでしょう? つまり、見た目とは関係のない部分です。

コンポーネントの中身を見ると、fromtolastPageを計算している部分がありますよね。ここがロジックの部分なわけです。ここだけを見ればただの関数なので、コンポーネントの外側にも切り出せますよね。見た目の振る舞いとロジックを分けましょうという話です。

グリスケ:なるほど、テストができる単位に分割しようね、ということですね。

先生:そうです。totalperPagecurrentPageを受け取って、fromtolastPageを返す関数をまず書いてみましょう。

グリスケ:……えーと、totalperPagecurrentPageを受け取って、必要な計算をして返すという関数にしてみました。

calcPage関数として切り出す

const calcPage = ({ total, perPage, currentPage }) => {
  const from = (currentPage - 1) * perPage + 1;
  const to = currentPage * perPage > total ? total : currentPage * perPage;
  const lastPage = parseInt((total - 1) / perPage, 10) + 1;

  return {
    from,
    to,
    lastPage,
  };
};    

先生:はい、準備ができましたね。これで動作が問題がないか、テストしてみましょう。

Vitestなどのテストランナーと呼ばれるツールを導入するのもいいですが、最初の一歩としては、とりあえずconsole.assert()のテストを付け足して、コンソールに出してみましょう。calcPage()関数が実行される際、一緒に読み込まれて実行できます。

グリスケ:えーと……。たとえば「全部で95ページのものを10ページずつ表示したら最後のページは10」というテストにすると……。

テストケース

console.assert(
    calcPage({total: 95, perPage: 10, currentPage: 1}).lastPage === 10
)

DevToolsのコンソールにエラーは出ないから合っている。トータルを100にしても……いちおう合っているみたいです。

他の数値もやってみると……。

テストケース2

console.assert(
  calcPage({total: 0, perPage: 10, currentPage: 1}).lastPage === 0
)

これは「失敗しました」が出てしまいます。

間違っているんだろうけど、どこを直せばいいんだろう。

テストケースを書き出す

先生:待ってください。行き当たりばったりでロジックを直すことを考える前に、まずは想定できるテストケースを全部書いてみましょう。

というより、そもそも「このページャーはどういう表示になってほしいか」という要件がないと、コードが上手く書けているかどうかが判断できません。私が最初に「このページャーの要件は?」と聞いたのは、そういう理由です。

たとえば、トータルのページ数よりも大きいページを指定したときはどうなるかとか、ページ数が0のときはどうなるかとか、そういうことを事前に「要件」として詰めておく必要があります。

その上で、その要件に沿って「こうなったらこうなることを期待する」というテストを書きます。要件として起こりうるパターンを想定できないといけないです。

グリスケ:そっか、テスト項目がちゃんとできていないと、テストしても意味がないですよね。

先生:そうです。そうしてテストを書いてエラーが出たら、ロジックが間違っているか、もしくは要件が間違っていたかのどちらかです。まあ、今回は今からここで要件を詰めていくのもなんなので、テストケースを書き出していくところから始めましょう。

グリスケ:わかりました。じゃあ、思いつく「こうなってほしい」というテストケースを書いてみます。

テストケースを書き出す

// 全データが存在しない場合、最後のページ数が`0`になる
console.assert(
  calcPage({total: 0, perPage: 10, currentPage: 1}).lastPage === 0
);

// 全データが95件あり、1ページに10件表示するとき、最後のページ数は`10`になる
console.assert(
  calcPage({total: 95, perPage: 10, currentPage: 1}).lastPage === 10
);

// 全データが95件あり、1ページに20件表示するとき、最後のページ数は`5`になる
console.assert(
  calcPage({total: 95, perPage: 20, currentPage: 1}).lastPage === 5
);

// 全データが95件あり、1ページに1件表示するとき、最後のページ数は`95`になる
console.assert(
  calcPage({total: 95, perPage: 1, currentPage: 1}).lastPage === 95
);

先生:複数のケースをまとめてテストするなら、まとめて配列で管理したほうが読みやすいですね。ループしてテストすればいいですし。

グリスケ:なるほど。じゃあ、配列にしてみます。ほかにもありそうだから加えてみよう。

テストケースを配列にまとめる

// `[引数としての入力, 期待される出力]`をペアにした配列
const testCases = [
  [
    { total: 95, perPage: 10, currentPage: 1 },
    { from: 1, to: 10, lastPage: 10 },
  ],
  [
    { total: 95, perPage: 20, currentPage: 1 },
    { from: 1, to: 20, lastPage: 5 },
  ],
  [
    { total: 95, perPage: 1, currentPage: 1 },
    { from: 1, to: 1, lastPage: 95 },
  ],
  [
    { total: 0, perPage: 10, currentPage: 1 },
    { from: 0, to: 0, lastPage: 0 },
  ],
  [
    { total: 95, perPage: 10, currentPage: 10 },
    { from: 91, to: 95, lastPage: 10 },
  ],
  [
    { total: 9, perPage: 10, currentPage: 1 },
    { from: 1, to: 9, lastPage: 1 },
  ],
];

testCases.forEach((item) => {
  const result = calcPage(item[0]);
  ["from", "to", "lastPage"].forEach((key) => {
    if (result[key] !== item[1][key]) {
      console.log(
        `${JSON.stringify(item[0])} ${key} should be ${item[1][key]}, but ${
          result[key]
        }`,
      );
    }
  });
});

totalperPagecurrentPageを引数として受け取って、fromtolastPageに想定している返り値を入れます。calcPage()関数に計算させて、結果をresultに入れます。期待値と実際の返り値を比較して、違うところがあれば、どれがおかしいかをコンソールに出力しています。

あ、コンソールにエラーが出ています。

エラー内容

{"total":0,"perPage":10,"currentPage":1} from should be 0, but 1
{"total":0,"perPage":10,"currentPage":1} lastPage should be 0, but 1

"total":0のとき、fromlastPage0じゃないといけないのに、1になっている、と出ています。このケースですね。

エラーが出たテストケース

  [
    { total: 0, perPage: 10, currentPage: 1 },
    { from: 0, to: 0, lastPage: 0 },
  ]

"total":0のときの取り回しがおかしい、のかな?

今のままだと、total0にすると、ページャーの表示もおかしいですよね。マイナスがでちゃう。そもそも、マイナスってアリだっけ?

Appコンポーネントの数値を変えてみる

export default function App() {
  return (
    <div className="App">
      <Pager
        total={0}
        perPage={10}
        currentPage={0}
        onChangePage={(page) => console.log(`Change page: ${page}`)}
      />
    </div>
  );
}

先生「アリ」かどうかはグリスケさんが決めることです。それこそ、「要件」ですね。

エラーになってほしいケース

先生:たとえば、トータルが0のときはどういう挙動になってほしいですか?

グリスケ:えーと、その場合はページャー自体が表示されないほうがいいですよね。ページがないのに、ページャーだけ表示されているのはおかしい。今はなにが起きてもページャーが表示されちゃう状態ですよね。

先生:そうですね、そうやっていろいろなパターンを考えて、今度は「こうなったらエラーになってほしい」というケースもテストする、と考えてみましょう。さっきは「エラーなくこうなってほしい」というテストケースを書きましたけど。

グリスケ:エラーになってほしいケースとしては、さっきも出た0が含まれる、それからマイナスの値が含まれる、ぐらいかな。えーと、エラーになってほしいケースはどうやって書いたらいいんだろう……。

先生:やり方はいろいろありますが、シンプルにやるなら、関数からエラーを投げるという手があります。

グリスケ:なるほど、Errorthrowすればいいんですね。

エラーを投げる

const calcPage = ({ total, perPage, currentPage }) => {
  if (total === 0 || perPage === 0 || currentPage === 0) {
    throw new Error();
  }
  const from = (currentPage - 1) * perPage + 1;
  const to = currentPage * perPage > total ? total : currentPage * perPage;

  const lastPage = parseInt((total - 1) / perPage, 10) + 1;

  if (currentPage > lastPage) {
    throw new Error();
  }

  return {
    from,
    to,
    lastPage,
  };
};

先生:そうですね。では、このエラーの取り回しはどうしますか? calcPage()関数を実行する側の話です。

グリスケ:数値がおかしいのにページャーだけ表示されるのはおかしいので、「エラーが返された場合はページャーを表示しない」ということにします!

えーと、Pagerコンポーネントのほうで、calcPage()がエラーを返した場合は何も表示しないようにすればいいですよね。Reactではコンポーネントがnullを返すと何も表示されないので、それを使います。

エラーが返されると何も表示しない

const Pager = ({ total, perPage, currentPage, onChangePage }) => {
  let result = {};
  try {
    result = calcPage({ total, perPage, currentPage });
  } catch {
    return null;
  }
  // ...

先生:では、このエラーを投げるケースもテストコードにしましょう。

グリスケ:テストコードでもtry/catchを使えばいいんですね。

エラーになってほしいケースの場合にエラーを表示する

const errorCases = [
  // 0を含むケース
  { total: 0, perPage: 10, currentPage: 1 },
  { total: 10, perPage: 0, currentPage: 1 },
  { total: 10, perPage: 10, currentPage: 0 },

  // 負の値を含むケース
  { total: -10, perPage: 10, currentPage: 0 },
  { total: 10, perPage: 0, currentPage: -1 },
  { total: 10, perPage: -10, currentPage: 0 },

  // 存在しないページを指定
  { total: 10, perPage: 10, currentPage: 100 },
];

for (const input of errorCases) {
  try {
    calcPage(input);
  } catch (err) { /* eslint-disable-line no-unused-vars */
    // エラーが発生するのが意図した挙動
    continue;
  }

  console.error(`Expect to throw!: ${JSON.stringify(input)}`);
}

errorCasesでエラーになってほしいパターンを7つ書きました。errorCasesでループして、calcPage(input)を実行します。エラーがスローされたら、そのままcontinueになりますが、catchできなかったらエラーが表示されるようなります。

先生:そうですね。これで、エラーになるケースもテストできるようになりましたね。だいたいのパターンは網羅できたかな。

完成

グリスケ:最終的にはこんなかんじになりました。

完成

export default function App() {
  return (
    <div className="App">
      <Pager
        total={10} // 合計のアイテム数
        perPage={10} // 1ページに表示するアイテム数
        currentPage={1} // 現在のページ
        onChangePage={(page) => console.log(`Change page: ${page}`)} // ページ変更時のコールバック
      />
    </div>
  );
}

const Pager = ({ total, perPage, currentPage, onChangePage }) => {
  let result = {};
  try {
    result = calcPage({ total, perPage, currentPage });
  } catch {
    return null; // calcPage()がエラーを返した場合は何も表示しない
  }

  return (
    <div className="pager">
      <button
        disabled={currentPage <= 1}
        onClick={() => onChangePage(currentPage - 1)}
      >
        前のページ
      </button>
      <span>{`${result.from} - ${result.to} / ${total}`}</span>
      <button
        disabled={currentPage >= result.lastPage}
        onClick={() => onChangePage(currentPage + 1)}
      >
        次のページ
      </button>
    </div>
  );
};

// アイテム数などを渡すと、表示範囲や最終ページを計算する関数
const calcPage = ({ total, perPage, currentPage }) => {
  if (total === 0 || perPage === 0 || currentPage === 0) {
    throw new Error();
  }

  const from = (currentPage - 1) * perPage + 1;
  const to = currentPage * perPage > total ? total : currentPage * perPage;

  const lastPage = parseInt((total - 1) / perPage, 10) + 1;

  if (currentPage > lastPage) {
    // errorCasesの最後のケースを考慮して、エラー判定を追加
    throw new Error();
  }

  return {
    from,
    to,
    lastPage,
  };
};

// (テストコードは省略)
// ...

うまくテストが反映できた気がします!

ソースコード

今回のグリスケさんのコードはこちらです。

2024-test-basic | codegrid

先生:今のままだと、コンポーネントのコードが実行される度に、console.assert()で書いたテストも実行されてしまいます。開発中はそれでも問題ないかもしれませんが、本番環境などユーザーには嬉しくないと思うので、別のファイルに切り出すもよし、テストツールを導入するもよし、なんらかの対処を忘れないようにしてください。

テストがあれば

グリスケ:あ、一つコードの書き方で迷っていたところがあることを思い出しました。calcPage()関数のところの最終ページを計算する関数です。

書き直したいところ

const lastPage = parseInt((total - 1) / perPage, 10) + 1;

小数を整数にするparseInt()より、Math.ceil()を使ったほうが、切り上げのほうが計算も少なく、コードの意図もわかりやすいですよね。なので、Math.ceil()を使ったほうがいいんじゃないかと思うんです。

こうしたい

const lastPage = Math.ceil(total / perPage);

先生:では修正して、テスト結果に影響ないかを確認してみてください。

グリスケ:はい、やってみます。……大丈夫みたいです!

先生きちんとテストを書いておくと、こうやってちょっと調整したときにも、バグが混入してないか確認できますね。

それにもし今後、グリスケさんが書いたコードの要件を確認されても、テストに書いてあればすぐに答えられますよね。

グリスケ:はい、よくわかりました。

終わりに

グリスケ:テストを書いておくと、自分のコードに自信が持てますね。テストを書かなきゃ! とあまり気負いすぎずに、まずは気になる部分のテストだけでも書いてみるとか、これからやっていきたいです。

テストっていうと、テストライブラリの使い方ばかり気になってしまいがちでした。今回、こうやって自分で書いてみると、どういうときにどう動いてほしいのかを考えたり、テストするロジックの切り出しを考えたりすることが必要でした。テストの本質ってこういうことなんだなって思いました。

先生:そうですね、そういうことがわかった上で、便利にできるよ、というのがテストライブラリです。

テストを書いている最中にも言いましたが、コードを書く前に、まずは「どういう挙動が正しいか、どういう挙動が正しくないか」そして「どういう場合にどういう挙動になるか」を考えることが大事です。それがないと明確なゴールが決められません。テストはそのゴールを確認するための手段です。

最初からテストコードが書きやすい実装や設計ができるよう、精進していきましょう。

杉浦 有右嗣
フロントエンド・エンジニア

SIerとしてシステム開発の上流工程を経験した後、大手インターネット企業でモバイルブラウザ向けソーシャルゲーム開発を数多く経験した。2015年にピクセルグリッドへ入社し、フロントエンド・エンジニアとして数々のWebアプリ制作を手掛ける。2018年に大手通信会社に転職し、低遅延配信の技術やプロトコルを使ったプラットフォームの開発と運用に携わっていたが、2020年ピクセルグリッドに再び入社。プライベートでのOSS公開やコントリビュート経験を活かしながら、実務ではクライアントにとって、ちょうどいいエンジニアリングを日々探求している。2025年、ピクセルグリッドを退社。

渡辺 由
PixelGrid Inc.
フロントエンド・エンジニア

Web制作会社、ECサイト運営会社にてマークアップや社内システム構築を担当したのち、大学の研究室のエンジニアとしてデータベースや解析ツールなどのWebアプリケーション開発に従事。インフラやサーバーサイドを含め、Web技術全般を幅広く経験したが、いま最も興味があるのはJavaSciptやCSSやUIの設計。2021年にピクセルグリッドに入社。

丸山 陽子
PixelGrid Inc.
編集

Mac雑誌の編集者を経験後、フリーランスのエディター/ライターとして独立。パソコン系雑誌やデジタルカメラ雑誌、iPhone/iPadの初心者向け書籍などの編集や執筆に携わる。2015年にピクセルグリッドへ入社し、「CodeGrid」の編集を担当する。

Xにポストする Blueskyにポストする この記事の内容についての意見・感想を送る

全記事アクセス+月4回配信、月額880円(税込)

CodeGridを購読する

初めてお申し込みの方には、30日間無料でお使いいただけます