テストの必要性と interface, mock について

interface を定義する意味。そして、なぜそのコードはテストしづらいのか


Posted on Sun, Mar 24, 2019
Tags test, interface, mock

テスト開発はソフトウェアエンジニアの一般教養になった

最近、プロダクションのコードにて、テストを書かないコードというのはありえないというのが一般常識であると思う。

自分が学生の頃、正直、テストや mock は単語レベルで知っていただけで、全然身についていなかった。

今、テスト開発はすべてのソフトウェアエンジニアの一般教養と言えるレベルだと思うので、データ構造とアルゴリズムの次の講義にソフトウェアテストの授業があっても良いくらいだと思っている。 (例えば、アルゴリズムに問題がないかどうかをチェックするとか。意図したようにソフトウェアが動いているかとか)

この記事では個人的な経験に基づいて、なぜテストが必要なのか、どうやって実装するのか、interface を作る意味について記述する。

この記事の論旨

まず先にこの記事で伝えたい内容を記載する

  1. テストが書かれていないコードは、遅かれ早かれ自分にブーメランとして返ってくる
  2. テストを書くのが難しい。では、その上でどうしたらよいのか?
  3. interface を書き、mock を作る
  4. 本番環境のクローンをテスト用に作る
  5. 一度テストを書いたら、あとはコピペで済むことが多い。だから工数は 2倍には絶対ならない

テストが書かれていないコードはどうなるのか

まず、重要なこととして、コードは編集されるたびにバグが混入する という認識をソフトウェアエンジニアは持っている必要があると思う。

レビューで防止できる部分もあるが、コードは往々にして複雑化していくため、プロダクションで動かすコードすべてにテストは行われる方がよい。

具体的にどうなるかという話をすると、自分の場合は テストされていない仕様がプロダクションで発覚し、休日深夜に復旧対応を行った

もちろん、すべてのプログラムがそんな対応を強いられるわけではないと思うが、テストは実装していたほうが、自分はメリットが多いと最近思っている。 例えば、事前に起こりそうなエッジケースに対して、どう振る舞うべきかを定義しておける

卑近な例で申し訳ないのだが、最近開発したデータ構造で、 https://github.com/go-zen-chu/time-sorted-list/blob/master/list_test.go#L149 というように、問題が発生しそうな入力に対して、どう振る舞うべきかを定義しておける。 これによって、実際にプロダクションでコードが稼働したときに、「いや、その入力に対しては、こう振る舞うはずだから」と可能性を潰した上で対応が行える。

自分が運用者じゃなかったとしても、運用者から質問がされることもあるだろう。テストを書く = 詳細設計をコードで表現する であるため、「そこの仕様はテスト的には〇〇となってますよ」とすぐに答えられるのである。

テストを書いていたほうが、明らかにトラブルシューティングが速いし、トラブルがあっても、次起こらないようにテストに事前に記述しておくことができる。

テストを書くのは難しい

ここまでの話でテストを書くメリットはなんとなく感じて頂けたら幸いだが、実際の話、テストを書くのは難しい。

自分もテストを書くのは、長い間苦手だった。 しかし、テストが書かれたプロジェクトのコード追加を行っているうちに、「あ、こういうふうに書けばいいのか」と理解が進んだ。

自分がおすすめしたいテストの追加方法は、今のプロジェクトに親しい言語やフレームワークの OSS のコードの github で探して、そのテストコードを眺めてから、実装を始めることである

最初にテストの書かれ方のイメージを掴んでから実装を行うと、TDD じゃないとしてもテストを書く敷居が下がると思う。

テストできないコード

さて、テストを書き始めようと立ち上がったとして、それでも 実はテストできないコード というのが存在する。

なんでそんなコードが発生するのか? 個人的には下記の2つがすぐに挙げられる

  1. テストをどうするかを考えずに実装が先走ってしまった(急いでいるときは仕方がない)
  2. テストするための環境構築が難しい(DB とか外部PF とつながっているとか)

interface を作る意義

  1. については、リファクタリングするしかない。そのためには、interface を切って、mock をつくるところから始める必要がある。

interface も僕が学生時代によく名前を聞いたけれども、「なんでそんなの必要なのかなぁ」と思っていた概念だ。

いま振り返ってみたら、当時、interface を切る必要のあるくらい大きいプロジェクトや問題に直面したことがなかった。 (普通に授業受けているだけでは出くわすことはほぼないだろう)

interface はそもそもの意味で「境界面」という意味だ。「すり合せするもの」と言っていいかもしれない。

また卑近な例になるが、https://godoc.org/github.com/go-zen-chu/time-sorted-list#ITimeSortedList でも interface を作っている。 しかし、これによるメリットはなんだろうか?

例えば、下記の行がプロダクションのコードで動いていたとする。

var(
  actualTsl ITimeSortedList
)

func hoge() {
   // たくさんの処理
   ti := actualTsl.GetItemsFromUntil(1553440647, 1553440657)
   // たくさんの処理
}

これでバグが起こり、損害が出たとする。さあ、早く問題を切り分けないといけない。

このとき、どうやら GetItemsFromUntil メソッドで返される配列が特定のデータを持つとき、バグが発生しそうだとわかってきた。 そこで、この行に関するテストを書いて確かめることにした。

actualTsl は ITimeSortedList という interface のため、決められたメソッドを実装さえしていれば中身はなんにでも置き換えられる。 例えば、1553440647 から 1553440657 までの間のデータだけ入れておいて、あとは空という状態でも interface さえ守ったオブジェクトがあればテストができる。そして、それが mock である。

上の例はデータ構造のため、比較的テストしやすい部類になるが、テストしづらいのは interface が作られていなかったり、DB の connect のような外部との接続が行われているコードである。

そのときは、外部につなぎにいくところを interface として切り出してしまって対応するoauth 認証のクライアントの例

本番と似たような環境で自動テストする

次に 2. については、最近だと GCP, AWS でテスト用のインスタンス立てたり、docker を使ったりでテスト用の環境を作りやすくなってきていると思う。 それらのツールを利用して、テスト用のクローン環境を作るところから始める必要がある。 (そうしないと、CI を行うことができないからだ)

実はこのときも、またテスト実装の話が重要で、テスト用の環境はもちろん本番と規模や性能の面で異なる。 そのため、「テスト環境と本番だとデータサイズが異なりそうだから、データサイズが大きいパターンのテストも書いておこう」と考えられるとよい。

また最近だと「そもそも本番で支障なければ(あるいは些細なレベルくらいだったら)、本番でテストする」という方法も普及してきた。

GAE だとリクエストの 5%だけ別の環境に送るということができるので、それができるならテスト用環境は不要かもしれない。

テストを書くのは、コード量は 2倍になるが、工数は 2倍ではない

さて、テストコードを書き慣れてくると、次第に「あ、この関数はこのテスト書けばいいや」とわかってくる。 そして、過去に見たテストコードからコピペしてテストコードは作ることができ始める。 こうなるとテストコードは次第にコピペ作業のように作ることができる。

なんでそうなるのだろうか?

プログラムをテストするということは、実際に開発するプログラムと比べて、作法が固まっている。 実際に開発するプログラムは色んなアルゴリズムやデータ構造から作られるが、テストコードはほぼテンプレートのようなものがある。 (もちろんテストを最適化していくとそれはそれでアルゴリズムやデータ構造を考える必要があり、時間がかかるが…)

ぜひ OSS のコードなどを見て、「このコードコピればテスト作れそう」という視野を得られるとテストを作るのが楽しくなってくる。

Happy testing!