テストが書きやすいってことはいいこと
Photo by Chris Liverani on Unsplash
テストが書きやすいということはいいことである。というのを信じて色々考えていこうと思います。
すごく単純なテスト
const echo = (message: string) => {
return message
}
test('echo', () => {
const actual = echo('test message');
expect(actual).toBe('test message');
})
すごく単純な関数とテストコードです。テストが書きやすいです。このコードの何がいいのか?を色々なパターンを考えて深掘りしていこうと思います。
せっかくなのでプログラミングを少し遡ってみながら考える
手続き型プログラミング
プログラムを「手続き(関数やサブルーチン)」の集まりとして設計する方法で、C言語やBASICが代表的です。
この手法の時にテストが難しくなるケースは以下のような時です。
const order = (item: string) => {
const price = getPrice(item)
saveOrder(item) // DBへ登録
return price
}
極端ではありますが、orderすることによって、DBへの登録までやってしまうようなコードです。ビジネス的に「orderは必ず記録したい」となっている場合、良さそうに見えます。
ですが、このコードをテストするときを考えると、「itemが登録されていない場合」「DBへの登録が失敗した場合」などを再現するのが難しくなっています。
test('unknown item', () => {
db.save = (item: string) => expect(item).toBeNull() // DBの処理をMockする
const actual = order('unknown item')
expect(actual).toBe(null)
})
テストコードだけを見ると、「itemが存在しない場合をテストしたい」だけにも関わらず、DBにも関心を寄せなければならないことがわかります。これはテストしづらいと言えるでしょう。
(「関心の分離ができていない」というやつです。)
オブジェクト指向プログラミング
みんな大好きオブジェクト指向プログラミングです。データとそれに関連する処理をひとまとまり(オブジェクト)として扱い、再利用性や保守性を高める設計手法です。
class OrderService {
private db: Database;
constructor() {
this.db = new Database(); // 自分でインスタンスを作っている
}
order(item: string) {
const price = getPrice(item);
this.db.save(item);
return price;
}
}
test('unknown item', () => {
const service = new OrderService();
const actual = service.order('unknown item')
expect(actual).toBe(null)
})
先ほどの例と機能は同じですが、オブジェクト指向プログラミングのカプセル化によってDBの処理をMockすることができず、よりテストが難しくなっています。
もう一つ代表的な例として、状態に関するものを挙げます。
class BankAccount {
private balance: number;
constructor(initialBalance: number) {
this.balance = initialBalance;
}
withdraw(amount: number): boolean {
if (this.balance >= amount) {
this.balance -= amount;
return true;
}
return false;
}
getBalance(): number {
return this.balance;
}
}
test('withdraw', () => {
const account = new BankAccount(1000);
const actual1 = account.withdraw(500);
expect(actual1).toBeTruthy()
const actual2 = account.withdraw(500);
expect(actual2).toBeTruthy()
const actual3 = account.withdraw(500);
expect(actual3).toBeFalsy()
})
このクラスは、インスタンス生成時に預金額を設定し、withdrawで引き出しが可能であれば預金を減らして成否を返します。
このような実装の場合、同じ関数呼び出しにも関わらず、結果が変化してしまいます。冪等性が失われ、再現性がないと言えます。実際に使用する場合も、インスタンスがどこまで共有されているか?で呼び出した時の結果予測が複雑になっていきます。
そのほかにも、オブジェクト指向プログラミングの苦しみパターンはありますが悲しくなるのでやめておきます。
テストが書きやすいということは
ここまでの例を見ると、
- 責任がわかりやすく1つである(単一責務の原則)
- 副作用がない(pure function)
- 冪等性が担保されている(同じ入力に対して同じ出力)
という特徴があります。テストコードも短く済む傾向にあると思います。
あらためてテストが書きやすいと何がいいのだろう
テストが書きやすい場合の特徴から考えてみると、シンプルになるということがわかると思います。実際に気をつけて書いてみると体験できると思いますが、コードの量や分岐が少なくなっていく傾向が見えてくると思います。
また、TDDとの相性もよくテストコードを合わせて書くことでテストが複雑になっていないか?をチェックしながら進めることができます。
そもそもテストというのは、実装した関数の最初のユーザーであり、ユーザーが使いにくいものがいいものである可能性は低いはずです。
さらに、気をつけたいのが、処理を分解しているだけで、全体の複雑さは変わらないのではないか?という疑問に対して戦うことです。シンプルにして責務を明確にすることで必要以上に複雑さを表出させないように戦うことができるはずです。複雑なものを簡単にするのがエンジニアリングの面白さですよね。
インターフェースの力
複雑なものを簡単にする方法としてインターフェース設計が強力に味方になってくれます。
インターフェースとは主にオブジェクト指向プログラミングで言及されますが、関数のパラメータも一種のインターフェースと考えていいと思います。
インターフェースの考え方は現実世界でも、当たり前のように存在していて、多大な影響を持っています。我々が、TeslaでもToyotaでもメーカーや車種に関係なく車を運転できるのも、「アクセルを踏む」というインターフェースを車が持っており、「スピードが上がる」という結果を約束してくれているからです。
インターフェースはユーザーへの要求であり、単純でわかりやすいものが好まれます。この傾向はプログラミングの世界でも同様に見られます。特にユーザーがどこまで関心を保つべきか?はシンプルさを考える上で重要です。
const auth = (awsToken: string) => {}
このような関数があった場合、authはawsの関心をユーザーに求めていると言えます。とはいえ認証をしたいユーザーはusernameとpasswordだけに関心を持ちたいと思うはずです。これはユーザーに求めすぎと言えると思います。
じゃあ、aws関連の関数をいくつも作ることはよくないの?などの疑問が浮かぶかもしれませんが、あくまでユーザーをどこに置くかによって変わってきます。今回想像してユーザーは画面の実装ぐらいだと思ってください。
const auth = (username: string, password: string) => {}
このようにユーザーに求める関心をできるだけ一般的なもの、ドメイン知識によるものにして、実装の制約にしないことで、実装の制約を変更しやすいというメリットも生まれます。
awsTokenの例のように、実装の制約、ここでは認証にAWSを使用するという制約が広く漏れ出ていることで、AWSからGCPに認証基盤を変えたいときに大きな変更を伴うでしょう。
一方で、usernameとpasswordの例であれば、auth関数を変更するだけで済むと予想できます。
このように、インターフェースを誰が決めるか?はすごく重要な設計のポイントで、あらゆる角度から見た場合を検討すると面白いと思います。
結局なんなの?
テストが書きやすいっていうのはいいことだよね?そうだよね?