なぜテストコードを書くのか?
はじめに
皆さんテストコード書いていますか?
著者は普段フロントエンド領域の実装をしているのですが、恥ずかしながらサービス立ち上げ時はテストコードを書かず進めていました。
しかしサービスが一定規模になるとバグが多発する経験をし、そこで初めてテストコードの大切さを学びました。
この経験をもとに「なぜテストコードを書くのか?」という考え方についてアウトプットしようと思います。
この記事の対象者
- エンジニア初学者
- ポートフォリオとか作っているけどテストコード書いたことない人
- 普段業務でテストコードを書いていない人
- テストコードというキーワードは知っているが、なぜ書く必要があるのかふわっとしている人
実際のコード
今回の解説で紹介するコードはこちらで見れます。
著者がフロントエンドエンジニアなので React と TypeScript を交えたコードを置いています。
テストコードとは
テストコードとは一言で述べると下記です。
- 自分の書いたコードが想定通りに動いているか確認するコード
業務での開発は 1 つのバグが大きな障害につながる可能性があります。
それを事前に防ぐためにプログラムレベルでチェックする作業の一つにテストコード実装があります。(他にも lint による書き方のチェックなどもプログラムレベルでのチェックに含まれる認識です)
テストコードを書くこと でバグを減らせるといった記事はよく見受けますが、それ以外のメリットも存在します。今回はそちらをまとめていこうと思います。
テストコードを書く理由
テストコードを書くメリットは下記です。それぞれ簡単なコードを紹介しながら解説していきます。
- 自分の実装の正しさを保証するため
- PR レビューの工数を下げる
- リファクタリングを実施しやすくするため
- コードの品質を高めるため
- テストそのものの工数を削減するため
- バグを潰すため
- 仕様書の代わりになるため
自分の実装の正しさを保証するため
こちらは皆さんもイメージがつくと思います。
自分の作った機能が本当に仕様網羅しているのか?正しく動くのかをテストコードで確認することができます。
もちろん機能を実装しきり手動確認でもチェック可能ですが、「本当に動くのかわからないまま実装を進めること & そういった心理状況で進めること」は関連機能の品質の低下につながると考えています。
例えばフォーム実装で半角英数字の正規表現チェックをする仕様があったとしましょう。
そしてあなたは上記の正規表現を用意しました。しかしその正規表現が本当に正しいのか手動確認まではわからず不安要素ですよね。
そこでテストコードを書くことによってあなたのコードが正しいのかを事前にチェックすることができます。
const halfWidthAlphanumericCharacters = /^[0-9]+$/;
describe("正規表現のテスト", () => {
describe("halfWidthAlphanumericCharacters", () => {
it("入力値が半角英数字のみの場合,有効", () => {
expect(halfWidthAlphanumericCharacters.test("01")).toBeTruthy();
});
it("入力値が半角英数字以外の場合,無効", () => {
expect(halfWidthAlphanumericCharacters.test("aあ!&")).toBeFalsy();
});
});
});
このように小さな機能 1 つ 1 つを検証することで機能全体の品質は大きく上がりますし、自分のコードに自信が持てます。
心理的安全性のためにもテストコードを書くことは有用だと考えます。
PR レビューの工数を下げる
テストコードは PR のレビュワー工数を下げると著者は考えます。
テストコードの書かれていない PR を見る場合レビュワーは仕様把握、コードの書き方チェック、手動で動作確認を実施する必要があります。
テストコードが書かれている PR の場合説明文を見て仕様把握でき、テストが通っているかをチェックすることで動作確認を省略することができます。(もちろんケースによっては手動確認が必要な時もあります)
このようにレビュワーの視点から見てもテストコードは書いてあるとありがたい存在です。
リファクタリングを実施しやすくするため
次にリファクタリングを実施しやすくするためです。
例えば既存のフォーム実装があったとして、そこの機能を改修するとしましょう。
仮にテストコードが書かれていなかった場合、リファクタリングし既存のフォーム機能が壊れていないことを確認するにはどうすればよいでしょうか?
- PR の差分や変更箇所を見て、影響箇所を特定。その上で動作確認を実施
- フォームの全機能を動作確認
上記のようなケースが考えられると思います。ただ 2 つの手法どちらも工数がかかることがイメージつくかと思います。
反対にフォームのテストコードが既に実装されている場合、リファクタリングした後のチェックとして「テストコードが通るか?」をチェックするだけで既存機能が壊れていないかをチェックすることができます。
例題では reducer.ts
がリファクタリングを実施しやすくするテストコードにあたります。
このテストコードがあることで、フォームのロジックが正常であることが担保されています。
よってリファクタリングや追加機能修正などが容易に実施できます。
import { reducer } from "./reducer";
import { initialState, requiredError } from "../index";
describe("記事投稿フォームの状態管理テスト", () => {
describe("エラー表示フラグ", () => {
it("アクションが発火されたらエラー表示フラグをtrueにする", () => {
expect(
reducer(initialState, {
type: "showErrorMessage",
})
).toEqual({
...initialState,
shouldShowError: true,
});
});
});
describe("記事タイトル", () => {
it("入力値が空でない場合、タイトルが入力値に更新されエラーなしがかえる", () => {
expect(
reducer(initialState, {
type: "changeArticleTitle",
payload: {
title: "リーダブルコード",
},
})
).toEqual({
...initialState,
title: {
value: "リーダブルコード",
errorMessage: undefined,
},
});
});
it("入力値が空の場合、タイトルが更新され必須入力エラーを返す", () => {
expect(
reducer(initialState, {
type: "changeArticleTitle",
payload: {
title: "",
},
})
).toEqual({
...initialState,
title: {
value: "",
errorMessage: requiredError,
},
});
});
});
describe("記事詳細", () => {
it("入力値が空でない場合、記事詳細が入力値に更新されエラーなしがかえる", () => {
expect(
reducer(initialState, {
type: "changeArticleDescription",
payload: {
description: "いろいろ学べる",
},
})
).toEqual({
...initialState,
description: {
value: "いろいろ学べる",
errorMessage: undefined,
},
});
});
it("入力値が空の場合、記事詳細が入力値に更新され必須入力エラーがかえる", () => {
expect(
reducer(initialState, {
type: "changeArticleDescription",
payload: {
description: "",
},
})
).toEqual({
...initialState,
description: {
value: "",
errorMessage: requiredError,
},
});
});
});
});
このようにテストコードはリファクタリングを実施しやすくする側面も持っています。
コードの品質を高めるため
テストコードはコードの品質を高める機会を与えてくれます。
テストコードを書く習慣がつくと「テストしやすいコード」について自然と考えるようになります。
巷でよく述べられるテスタビリティなコードというやつですね。
具体的な例を見ていきましょう。
現在日時とあるデータの日時を比較して現在日時より後なのかを判定する関数を作るとします。
テストコードを考慮せずに書くと下記のようになると思います。
const isAfterBadExample = (targetDate: Date): boolean => {
const currentDate = new Date();
return currentDate.getTime() < targetDate.getTime();
};
一見よさそうに見えますがテストコードで判定する際に問題が生じます。
currentDate
がテストコードを実行するたびに変わってしまうことです。targetDate
を一年後とかにすれば一旦は大丈夫ですが、いつかはテストが落ちるので良くない実装です。
上記問題を解決しテストしやすくするためにはどうすれば良いでしょうか?
答えは currentDate
を引数として受け取ることです。
const isAfterGoodExample = (currentDate: Date, targetDate: Date): boolean => {
return currentDate.getTime() < targetDate.getTime();
};
describe("日付のテスト", () => {
it("対照の日時が現在の日時より後の場合trueを返す", () => {
expect(
isAfterGoodExample(
new Date("1995-12-17T03:24:00"),
new Date("1995-12-17T03:24:01")
)
).toBeTruthy();
});
it("対照の日時が現在の日時より前の場合falseを返す", () => {
expect(
isAfterGoodExample(
new Date("1995-12-17T03:24:00"),
new Date("1995-12-17T03:23:59")
)
).toBeFalsy();
});
});
currentDate
を引数に取ることで境界値のテストができましたね。
上記例のようにテストコードを書くことでコードの品質を高めるきっかけが自然と増えていきます。
テストそのものの工数を削減するため
テストコードはテストそのものの工数を下げてくれます。
こちらは「リファクタリングの実施」でも述べましたが、テストコードを記載することで手動確認の工数を削減することができます。
昨今のシステムは複雑なものが多く、毎回手動確認で再テストするのはとても工数がかかると思います。
また手動確認は「人間」が実施するのでもしかすると再テストである特定の動作確認が抜けているかもしれません。
そういったヒューマンエラーや開発工数を減らすという側面もテストコードは持っています。
バグを潰すため
テストコードはバグを潰す役割も持っています。
実際システムを運用する中でバグが発生することがあると思います。
その際にただ修正するだけだと、別のバグを起こしているかもしれません。
そこでテストコードを書くことで再発防止につながるのと、修正できている証明となります。
バグが発生した際はテスト駆動開発で紹介されている「レッド・グリーン・リファクタ」の手順で実装することをお勧めします。
- コードを修正する前にテストを書いて、あえてテストを落とす
- 上記によって不具合があることを確認できる
- テストが通るようにコードを修正する
- テストが通ったら、可読性が高い形にリファクタリング
仕様書の代わりとなるため
テストコードは仕様書としての役割も果たしてくれます。
どの言語もテストコードに説明文と特定ケースの期待される振る舞いを明記します。
上記より仕様把握の方法として、コード読み込む以外にテストを読むことで仕様把握ができるようになります。
さらにテストコードが存在するので動かしながら確認することも可能です。
自分の担当した機能を定期的に修正することは少なく、他のメンバーや半年後の自分が触ることなどが多いと思います。
そういった時に例題のようなテストコードによる説明文があると仕様把握が効率的に把握できます。
まとめ
テストコードがもたらす効果について色々と述べさせていただきました。
どの特徴も PJ メンバー全員が幸せになるものだと感じており、テストコードが書けることはとても強力なスキルだと思います。
この記事を通してテストコード実装に興味を持っていただけると幸いです 🙏
Discussion