この章では自動テストについて取り扱います。プログラム開発でテストというと、大まかに分けて、手動と自動のそれぞれのテストがあります。
前者は、よくQAとかドッグフーディングとか、実際に手で動かして、人間が挙動を確認するものです。 後者は、ユニットテストやE2Eテストなどさまざまな方法で、コンピュータが挙動を核にするものです。
自動テストにはどういう利点があるでしょうか?自動で行われるため、Gitのcommitに連動して自動でテストを行う、 あるいはもっと細かい単位、たとえば、ファイルを保存したらテストが走るということが可能です。
そのため、いくつもの種類のある自動テストの中でもテストが早く可能なものは、 日常的な開発の一部としてサイクルに組み込めます。 長時間が掛かるテストでも、リリース用の準備をするときなどには重宝します。
自動テストがもたらすものの1つは、開発に対する強い安心感です。 コードを変更しても障害が発生する確率が低いなど、開発に対して安心感をもたらし、 不安によって開発速度を落とさないという効果があります。
他にも、自動テストのコードが1つのサンプルとして働くことにも利点があります。 自動テストのコードを見れば、そのテスト対象が何をしているのかがはっきりするからです。 OSSのコードリーディングをする場合でも、ユニットテストがあればユニットテストから読んでいくと楽でしょう。
ユニットテストは、基本的には、関数のような小さな単位を自動テストするための仕組みです。 せいぜいクラスのメソッドが限度です。それ以上の大きな単位のテストは相応しくありません。
- 単位が大きくなると責務が増大し、テスト自体が複雑怪奇になる
- テストの為の準備が大げさになり、テストしたいことが何かが不明瞭になる
じつはユニットテストは奇をてらったことはしません。 ある値と状況(モックなどを含む)を与えた時に、どういう経路を通って、どういう結果を返すか、 それだけをテストするのがユニットテストです。
きっちりユニットテストが行き通ってる(テスタブル)なプロダクトでのテストコードは、 慣れてない人が見ると「え?いちいちこれをテストする必要があるの?」というくらいシンプルになります。
- テスト対象コードの経路の確認だけを行う
- ある引数と状況を与えたときに、結果を確認する
ユニットテストはこれだけを行うものです。
もしこれ以上のことをやっていたら、それはユニットテストとしては過剰なので責務を見直しましょう。
ファイルセーブをするたびにユニットテストが走る状態がユニットテストの理想型です。 これが実現されていれば、半端じゃない安心感に包まれた状態で、高パフォーマンス開発が可能になります。
ユニットテストは、基本的には副作用の無い関数に対して行われるのが理想ですが、 実際には副作用を含んだコードを自動テストしたいということもあるでしょう。
たとえば、ファイルシステムにアクセスするコードをテストする場合には、 本来ならば対象のコードを動かせばファイルシステムを読み書きしてしまうからです。 これには2つの明確な問題があります。
1つめは、ファイルシステムを読み書きするテストは、そうじゃないテストよりは圧倒的に重い(遅い)テストになります。 2つめは、ロジック自体のテストと、ファイルシステム自体のテストの2つの役割をもってしまうことです。
そこでよく使われるのはスタブとかモックと呼ばれる技術です。
// jest を使って JavaScript のコードをテストする
const { read } = require('./fs-impl')
jest.mock('fs')
const fs = require('fs')
test('file read', () => {
jest.resetMock()
fs.readFile.mockImplementation = (filename, opt, cb) => {
cb('dummy data')
}
expect(read('hoge.txt')).toBe('dummy data')
expect(fs.readFile.mocks.calls.length === 1)
ecpect(fs.readFile.mocks.calls[0][0]).toBe('hoge.txt')
ecpect(fs.readFile.mocks.calls[0][1]).toEqual({ encoding: 'utf-8' })
})
JavaScript界隈で最近もっともメジャーなユニットテストランナーがjestであり、
jestでモックを使う場合、モジュールごとにモックを仕掛けるのがjest.mock(モジュール名)
です。
この場合、fs
というモジュールを読み込むと全てモックが仕込まれたダミー関数にすり替わっています。
mockImplementation
はそのモックが呼び出されたときに、本来のファイルシステムモジュールが
持っていた関数の変わりに実行されるものです。これにより、実ファイルシステムにアクセスせずに、
ユニットテストが可能になります。
またモック化した関数が正しく呼ばれているか?正しい回数呼び出されているかを確認します。
mocks.calls
は、呼び出したときの引数を保存するものです。
1回しかその関数が呼び出されてないのであれば mocks.calls.length === 1
になります。
jest.resetMock()
を呼び出すことでモックの呼び出し回数などをリセットします。
結合テストは、複数のモジュールやシステム間をつないで、端から端までをテストするものです。 ダミーサーバーを立ち上げることもありますし、場合によっては実際に稼働しているテスト用サーバーに 接続することもあります。
ユニットテストはあくまでロジックとプログラムの制御の正しさを保証するためのものです。 もう少しいえば、関数やメソッド単位のAPIの挙動を保証するためのものです。
ところが、実際のプログラミングではAPIといった単位の保証をしても仕方ありません。 ユーザーがボタンを押したら、サーバー側に通信が行って、乱数によりガチャの結果が決まって、 ユーザーの保持するデッキにUR(ウルトラレア)などのカードが追加されているかどうか確認したいこともあるはずです。
クリーンアーキテクチャではこれらも大半をユニットテストで済ませることができてしまいますが、 そうはいっても、モジュールの結合、システムの結合などを行いたい場合もあります。
そこで、シナリオを決めた上で、実際にシナリオに沿って、必要なデータの更新、ユーザーへの見た目の変化 などが正しく行われているか?を確認する必要があることもあります。
これらは結合テストやE2E(End to End)テストと呼ばれるもので実現されます。
ただし、当然のことながらこういったテストはとても重く時間のかかるテストですし、 シナリオを組み立てるのもそれなりに大変です。またGUIの場合は、どこまでを厳密にテストすべきか? という問題があります。やり過ぎると、コストばかり掛かってあまり意味の無いテストになりがちです。
テストハーネスはレガシーコード1から脱却するために作られるテストのことで、最初の安全器具(ハーネス)です。 これは、結合テストのように、人間が動作を確認できて、ある程度の大きさを持った部分を区切って、挙動をテストコードとして表現します。
自動テストが無い環境では、いきなりユニットテストを書くとだいたい破綻します。 ユニットテストにあった構造ではなく、テストが複雑怪奇になりやすいという問題があります。
- 人間が手動テストを元に、「ここをこうするとこういう挙動を示す」という観測を行う
- 観測をテストハーネスに落とし込む
- テストハーネスが正しく動くか?わざと壊したときにテストが落ちることを確認する
- テストハーネスを元に破壊的リファクタリングを行う
- ユニットテストが書きやすいように設計を変更できたら、ユニットテストを追加する
1〜5を続けると、少しずつコードが整理されユニットテストが増えます。 もちろん途中、テストハーネスは文字どおりの命綱です。この命綱に身を預けて、リファクタリングとユニットテスト作成を繰り返します。 リファクタリングが進んで、ユニットテストが増え出すと、レガシーコードは少しずつ、 モダンなコード(ユニットテストがあって、安心できるコード)に生まれ変わります。
自動テストを書く時に問題となることとして、この引数を渡したら「こういう結果が帰ってくる」という詳細を決めることが難しいことがあります。
コードを改修して、以前と違う結果になったら、警告するという仕組みがあればそれでこと足りるというケースもあるでしょう。 厳密な数値の算出が目的でなければ、改修前後でデータが大きく狂ってなければありと判断できるかもしれません。 そういうときにはスナップショットテストが便利です。
細かいデータは違っていても、表示はほとんど変わらずユーザーにほぼ影響を及ぼさないこともあります。
スナップショットテストを画像に絞ったケースもあります。
たとえば、GUIコンポーネントを変更したときに、1ピクセルずれることになった、色味が少し変わった、 そういったとき、それは致命的でしょうか?
もちろん厳格なデザインポリシーがあって、それに違反するようなことがあれば大変ですが、 多くの場合、ちょっとした誤差なら許容できるかもしれません。
画像を比較して、違いのある部分だけ表示してくれる仕組みがあれば、 開発者・デザイナーが見て、許容するかどうか判断できるようになります。
ファイルシステムやリレーショナルDBは、何かしら細かいところに方言や微妙なくせにときどき致命的な問題をもたらすことがあります。 たとえば、macOS, Windows, UNIX/Linux のファイルシステムなどでは、使用可能文字、許容文字数、Unicode文字列の扱い方などに違いがあります。 下手をすると、OSのバージョンや、インストールしているものによってすら違いが生じます。
ユニットテストはあくまで「自分たちが全て知っていること」が前提です。 ファイルシステムの実挙動など、場合によっては「把握してないこと」にも対処しなければなりません。 そういった時の為に詳細(具象)に踏み込むテストを書いておくと便利です。
性質上こういったテストはユニットテストは比べものにならないくらい、実行時間が掛かりますし、注意深く書かないと冪等性の問題が生じたり、 システムに問題(書いてはいけない領域にファイルを書き出したりするような)が生じることもあります。
リレーショナルデータベースは、元々の理想である集合理論や宣言的記述に対して、割と泥臭く個別の事情に支配されてしまう側面があります。 このようなときに望ましいのは、本番環境と同等のスペックを持ったテストDBを用意して実際にSQLを走らせることです。 速度チューニングや、SQLの問題点をあぶり出したりする目的に最適です。
- 絵文字の取り扱い
- 文字列比較
- 複雑なSQLがインデックスを利用して実行されるかどうか?
本番データをバックアップするタイミングなどで、個人情報の入ったカラムをマスク情報に置き換えた上で、 開発者が自由に触れる環境にコピーしておくと便利です。
開発者にとって必要なのは、大抵の場合個人情報ではなく、他のカラムに入ったデータの組み合わせです。 また、件数なんかも予算が許す限りで本番に近づけると、パフォーマンスの問題を発見しやすくなります。
テスト対象の関数に、ランダムな数値などをぶち込むことで、テストが落ちないかどうかを確認するようなテストです。 HaskellのQuickcheckが有名ですが、他のOSでももちろん実現は可能です。
Test Driven Developemnt(テスト駆動開発)はとても有名でありながら誤解されやすいものです。
TDDでは、ユニットテストを記述することで、関数やメソッドの挙動(つまりAPIです)を手探りで設計・開発していく方法です。
あるモジュールを開発する時に最初からベストなAPIを定義できるとは限りません。 最初からAPIを定義するのをトップダウンな設計論だとすれば、TDDはボトムアップな設計論です。
- 入力となるデータを定義して、それに対応する出力データを定義し、それに相応しい責務とAPIを考えます
- そのデータの組み合わせとAPIに相応しいテストコードを書く、あるいはいきなりAPIを実装します
- テストコードが先に書かれたのであれば、APIを実装します
- 先にAPIが実装されていて(これは絶対にバグはない)という自信がなければテストコードを書きます
ここでのミソは、絶対の自信があるならテストコードは不要という点です。あと、必ずしもテストを先に書く必要はありません。 テストコードを先に書かなければならないという原理主義は TDD ではなく「テストファースト」という技法です。
もう一度書きます。TDDは、入出力のデータおよびそれを取り扱う責務分割とAPI設計が中心部分です。それ以外はどうでもいいです。 テストファーストなどは、中心部分をより安心しながらテンポ良く書く為の、安全性を確保するための道具に過ぎません。
TDDはテスト駆動開発の略ですが、設計論 です。 データ入出力と、それに相応しい責務と、APIを設計するための 設計論 です。
テストファーストは必須ではありません。 そもそもテストコードも必須ではありません。
BDDは Behavier Driven Development(振る舞い駆動開発)の略で、TDDと同様のものと見なされていますが、
振る舞いを記述した仕様書(Specification)を、自然言語に近いDSLで記述して行うTDDの亜種です。
TDDと同じく設計論として実践することもできますし、仕様書を自然言語に近いDSLで記述することにのみこだわることもできます。
テストファーストは、テストを先に書くという、少し細かい 実装的方法論です。
- テストとして最低限のものを書く(このときテストは必ず失敗するようにする)
- テストを通すだけの最低限の実装を書く(テストが通るが、データ決め打ちなど非実用的なもの)
- まともな実装に書き直す
テストファーストは、TDDとセットで考えられがちな方法論、いってみればコツです。
カバレッジは、テストが通る経路が、実コードのうち何%か?という指標です。
一部にはカバレッジ100%信仰がありますが、必ずしも100%である必要はありません。
大企業病を煩っていると、カバレッジのようなわかりやすい数値は、開発の目安におもえるかもしれませんが、 開発者が安心して開発できるカバレッジさえ確保されていれば、何%でもかまいません。
t_wadaが開発したとても賢いassert機構です。
元々assert
は、C言語のころからあるような古くさい仕組みで、assert
の中の式が偽になったらそこでエラーで落ちるというだけのものです。
power-assert
はメタプログラミングを駆使して、assert
の中の計算式を分解し、全ての変数の値や計算途中の結果を表示しつつ、
どの変数や計算式が、落ちる原因になるのかをビジュアル的に見せてくれます。
Footnotes
-
レガシーコードガイドにてレガシーコードとは、ユニットテストが無いコードと定義されています。 ↩