単体テストを書くメリット
- 投稿日:
単体テストを書くことには賛否両論あると思うが、私は書いたほうが良いと考えている。これは単に単体テストが書けるからだけに限らず、様々な恩恵があるからだ。
今回は単体テストを書くことで、副次的に得られる恩恵を書いていく。ここではクラスを使わず、関数で実装していくタイプの開発にフォーカスして話す。Reactの関数コンポーネントのような、関数型ではないが、クラスは使わないみたいなやつだ。
関数が小さくなる
単体テストは関数やクラス単体の振る舞いを見るのが目的だが、1000行あって、中にクロージャや分岐、ループがネストされていると非常にテストがしづらくなる。つまり単体テストをしやすくするために関数のサイズは必然的に小さくなる。理想的には関数が持つ責務が一つになるはずだ。
関数が小さくなれば変数のスコープも短くなるためコードの見通しが良くなり、長大な変数名を減らせるなど、いろいろメリットがある。極端な話だがGoでは1文字変数が市民権を得ていて、これは関数のスコープを短くする文化があるので成立している。
疎結合になる
単体テストを書く場合に密結合だとテストが書きづらい。そのため疎結合にするようになる。具体的に言うと依存性を逆転させるようになる。依存性が逆転していると、依存を注入できるためテストがしやすくなるのだ。
例えば以下のプログラムはDate ()
をモックする必要が出てくるので専用のライブラリを入れたり、何かしらのハックを使う必要があるだろう。
const isOneDayLater = (time: number) => {
return (+new Date() - time) > 86_400_000;
}
しかし、以下のように外部から値を注入すればモックしなくともテストコードを書くのが容易になる。第二引数に値を入れればいいからだ。
const isOneDayLater = (time: number, baseEpochTime: number) => {
return (baseEpochTime - time) > 86_400_000;
}
コードの見通しが良くなる
例えば次のような関数は冗長だ。この程度の長さならまだしも、これが500行、1000行と増えていくと読むのが大変になる。現実ではもっとネストが酷かったり、前後に長大関数がいて、注意深く読まないと解読不能ということもままある。そしてこの関数をテストするのは書いてある処理すべてを検査する必要があるので、テストコードも長くなり、しんどい。以下のようなコードを私は「べた書き」と呼んでいる。
type User = {
id: string;
firstName: string;
lastName: string;
gender: string;
address: string;
};
export const registerUser = async (user: User) => {
if (!/^\d+$/.test(user.id)) {
throw new Error('ユーザーIDの書式が不正です。');
}
if (user.firstName === '') {
throw new Error('姓が入力されていません。');
}
if (user.lastName === '') {
throw new Error('名が入力されていません。');
}
if (user.gender === '') {
throw new Error('性別が入力されていません。');
}
if (user.address === '') {
throw new Error('住所が入力されていません。');
}
const resp = await fetch('https://example.com/api/v1/user', {
method: 'POST',
body: JSON.stringify({
id: Number(user.id),
name: `${user.firstName} ${user.lastName}`,
gender:
user.gender === '0' ? 'male' : user.gender === '1' ? 'female' : 'other',
address: user.address
})
})
.then(async (resp) => await resp.json())
.catch((e) => e);
if (resp.status === 200) {
return resp;
} else {
const payl = resp.json();
return {
errorMessage: payl.errorMessage;
}
}
};
しかし次のように書けば34行あった関数が9行にまで減り、見通しが良くなる。関数の中で何をしているかわからないという声もあるだろうが、関数名から内容を読み取れるようにしておけば問題にならない。読み取れない処理を入れなければいいのだ。こういう風にべた書きを一定の粒度で関数化することをモジュール化と呼ぶ。
type User = {
id: string;
firstName: string;
lastName: string;
gender: string;
address: string;
};
export const registerUser = async (user: User) => {
validateUser(user);
const payload = createRegisterUserPayload(user);
const resp = await requestRegisterUser(payload);
return validateRegisterUserResponse(resp);
};
例えばvalidateUser()
の中でuser
バリデーションと関係ない処理、例えばサービスプロバイダを通して値の授受をしたり、どこかのAPIを叩いてたりすると、全く信用できなくなる。これは設計段階で関数の中身を単一責務にする制約を課すことで回避できる。開発標準に盛り込んでおくことが望ましいだろう。
単一責務で関数にまとめると、単体テストはその関数単位にできるためスコープが狭まり、テストコードが書きやすくなる。親の関数はどうするのか?という疑問が出るが、これは関数を呼んでいるかや、処理フローが正しく流れるかを検査すればいい。実際に関数を呼ぶ必要もなく、呼び出し関数はモックでよい。そうでないと結合テストになってしまうからだ。ただし問題もある、モック化した関数を呼び出すと本来と違う振る舞いをさせることもできてしまう。
これは実装やテストコードで型を騙さないことである程度防ぐことが可能だ。型が一致していない場合エラーとなるため、インターフェースレベルで不正なテストが書かれてしまう可能性を減らすことができる。勿論、どれだけやってもモックを呼び出しているのでテストが不正に通過する可能性はある。
テストが不正に通過する可能性を低めるためにはテストコードを書いたときや修正したときに、意図的に落ちる実装に変更してテストが落ちるかどうかを確かめたり、カバレッジの変化を見たり、結合テストを書いたり、手動での動作確認をするのが有効だ。
自動テストを書いているのに手動で確認するのかとか、結合テストを書いたら二重テストになって無駄ではないかというのもあると思うが、どちらも網羅的にする必要はなく、クリティカルパスが通っていれば、後の単体テストは問題なく通ると考えてよい。要するに正常系と異常系の2パターンを見て問題なければ、単体テスト側も問題ないだろうという考えだ。
では、そもそもの視点が間違っていたらとか、それでも単体テストが間違っていたら…というのも勿論ある、あるのだが、無限に追及していると際限がなくなるし、結果論として網羅的でバグのないe2eテストという非現実な解が出てきてしまう。単体テストはあくまでその製造コストの低さから条件を網羅しやすく、高速に動作するため、不具合の早期発見や、開発中の微修正を起因とした手動フルチェックを防いだり、何が書いてるのかイマイチわからない巨大な結合テストコードを減らすのに貢献するもので、バグをゼロにするためのものではない。
同じことを何度もテストしなくてよくなる
レビュー指摘などでロジックを直したり、不具合に気付いて直したりしたときに毎回全数テストをするのはどう考えても非効率である。しかし単体テストがあれば、その工数を減らすことは可能だ。特に分岐網羅に対しては非常に強力なアドバンテージがある。仮に分岐パターンが4個程度しかないとしても、手動でやっていては効率が悪い。コード直して起動するだけで手間なのに、更に操作とかやってられない。
手動テストはなくならない
手動テストが減ることはあっても、なくなることはない。何故なら初回実装時はそもそも動くかどうかすら解らないのだから、この時ばかりは見る必要がある。手動テストで問題がなかったからこそ、以後の単体テストが信頼できる状態になる。端から動いていなければ、テストコードは意味をなさない。過去に手動確認し、その時に通っていたテストコードだからこそ信頼できるのである。