脱初心者のためのテストの書き方 テストを書く手順
自分が書いたコードに自信がないとき、確かめる一つの手段に「テストを書くこと」があります。テストライブラリなど、便利なツールもありますが、そもそもテストとは何か、何をテストするのか、というところから始めてみましょう。
自分が書いたコードに自信がない
グリスケ:すみません、ちょっとコードを見ていただきたいんですが……。
先生:おや、あなたは、先月中途採用で入社したグリスケさんですね。確か、グリオ君の学生時代の先輩じゃなかったでしたっけ?
グリオ君ってだれ?
グリオ君は、入門したてのエンジニア見習い。ミニゲームを作ってみたり、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コンポーネントの中身です。total、perPage、currentPage、onChangePageを受け取って、表示中の最初のアイテム番号のfrom、表示中の最後のアイテム番号のto、「次のページ」ボタンが押せるかどうかを判定するのに必要な最後のページ番号lastPageを計算します。
表示では、「前のページ」ボタンはdisabled={currentPage <= 1}にしているので、現在のページが1ページ目のときは押せなくなります。そうでなければ、onChangePage(currentPage - 1)で前のページに移動します。「次のページ」はその逆です。
先生:そうですね。さて、このコードでいいのか不安、ということですよね。自信がないなら、テストを書きましょう。
グリスケ:え、コンポーネントのテストですか……。
先生:いえいえ、ここはコンポーネントが問題ではないですよ。
ロジックを分離する
先生:このコードで、ロジックの部分はどこでしょう? つまり、見た目とは関係のない部分です。
コンポーネントの中身を見ると、fromとtoとlastPageを計算している部分がありますよね。ここがロジックの部分なわけです。ここだけを見ればただの関数なので、コンポーネントの外側にも切り出せますよね。見た目の振る舞いとロジックを分けましょうという話です。
グリスケ:なるほど、テストができる単位に分割しようね、ということですね。
先生:そうです。totalとperPageとcurrentPageを受け取って、fromとtoとlastPageを返す関数をまず書いてみましょう。
グリスケ:……えーと、totalとperPageとcurrentPageを受け取って、必要な計算をして返すという関数にしてみました。
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]
}`,
);
}
});
});totalとperPageとcurrentPageを引数として受け取って、fromとtoとlastPageに想定している返り値を入れます。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のとき、fromとlastPageが0じゃないといけないのに、1になっている、と出ています。このケースですね。
エラーが出たテストケース
[
{ total: 0, perPage: 10, currentPage: 1 },
{ from: 0, to: 0, lastPage: 0 },
]"total":0のときの取り回しがおかしい、のかな?
今のままだと、totalを0にすると、ページャーの表示もおかしいですよね。マイナスがでちゃう。そもそも、マイナスってアリだっけ?
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が含まれる、それからマイナスの値が含まれる、ぐらいかな。えーと、エラーになってほしいケースはどうやって書いたらいいんだろう……。
先生:やり方はいろいろありますが、シンプルにやるなら、関数からエラーを投げるという手があります。
グリスケ:なるほど、Errorをthrowすればいいんですね。
エラーを投げる
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,
};
};
// (テストコードは省略)
// ...うまくテストが反映できた気がします!
ソースコード
今回のグリスケさんのコードはこちらです。
先生:今のままだと、コンポーネントのコードが実行される度に、console.assert()で書いたテストも実行されてしまいます。開発中はそれでも問題ないかもしれませんが、本番環境などユーザーには嬉しくないと思うので、別のファイルに切り出すもよし、テストツールを導入するもよし、なんらかの対処を忘れないようにしてください。
テストがあれば
グリスケ:あ、一つコードの書き方で迷っていたところがあることを思い出しました。calcPage()関数のところの最終ページを計算する関数です。
書き直したいところ
const lastPage = parseInt((total - 1) / perPage, 10) + 1;小数を整数にするparseInt()より、Math.ceil()を使ったほうが、切り上げのほうが計算も少なく、コードの意図もわかりやすいですよね。なので、Math.ceil()を使ったほうがいいんじゃないかと思うんです。
こうしたい
const lastPage = Math.ceil(total / perPage);先生:では修正して、テスト結果に影響ないかを確認してみてください。
グリスケ:はい、やってみます。……大丈夫みたいです!
先生:きちんとテストを書いておくと、こうやってちょっと調整したときにも、バグが混入してないか確認できますね。
それにもし今後、グリスケさんが書いたコードの要件を確認されても、テストに書いてあればすぐに答えられますよね。
グリスケ:はい、よくわかりました。
終わりに
グリスケ:テストを書いておくと、自分のコードに自信が持てますね。テストを書かなきゃ! とあまり気負いすぎずに、まずは気になる部分のテストだけでも書いてみるとか、これからやっていきたいです。
テストっていうと、テストライブラリの使い方ばかり気になってしまいがちでした。今回、こうやって自分で書いてみると、どういうときにどう動いてほしいのかを考えたり、テストするロジックの切り出しを考えたりすることが必要でした。テストの本質ってこういうことなんだなって思いました。
先生:そうですね、そういうことがわかった上で、便利にできるよ、というのがテストライブラリです。
テストを書いている最中にも言いましたが、コードを書く前に、まずは「どういう挙動が正しいか、どういう挙動が正しくないか」そして「どういう場合にどういう挙動になるか」を考えることが大事です。それがないと明確なゴールが決められません。テストはそのゴールを確認するための手段です。
最初からテストコードが書きやすい実装や設計ができるよう、精進していきましょう。