📚

今度こそユニットテストを書き始めるために

2021/07/25に公開

はじめに

Unit Testが大事、ということ自体はあまり異論はないと思うのですが、最初からTDDがしっかりできてるような現場ならいざ知らず、そうではない場合は中々うまく入れれない事も多くあります。なのでこうすると導入しやすい、という観点で以下の動画でそのあたりのことを話したのですが、補足も含めて記事でもまとめておきたいと思います。

https://www.youtube.com/watch?v=IDwz-sCwgHA

これはユニットテストですか?

ユニットテストとは?

ユニットテストとは何でしょうか? 一応、テストの資格試験を実施しているISTQBの定義では以下のように定義されます。

component testing (unit testing)
A test level that focuses on individual hardware or software components.
Synonyms: module testing, unit testing

この場合、ユニットテストとコンポーネントテストは同義で、コンポーネントに対するテストがUTだよ、と書かれています。ただ実際には動画でも話したように この定義は会社やチームによって違うのが現実です。「分解不可能な粒度のコンポーネントって何?」って部分がぶれるからですね。関数単位のテストが主流だとは思いますが Web画面を叩いてユニットテスト! ってはじめて言われたときにはびっくりしましたね。あとユニットテストが暗黙のうちにJUnitやRSpecのような自動テストを指すこともあれば、当然のように手作業の事もあります。このあたりは各組織できちんと定義して周知しておけば何でも良いと思いますが認識を合わせるのは大事です。

またレガシーコード改善ガイドでは以下のような自動テストをユニットテストと定義しています。

  • 実行が速い
  • 問題個所の特定がしやすい

本の中では 「0.1秒の単体テストは遅い」 と言っています。実行の速さとはそのレベルです。何故ならばビルドのタイミングでプすべてのUTを実行する事を前提にしているためです。1つのテストケースが0.1秒もあると3000個のクラスがあったとして一つあたり10件テストを書くと3万件、合計実行時間が1時間近く掛かってしまいます。。。遅すぎますよね?
この状態はテストスキップなどの悪いプラクティスを招く可能性が高くUTにおいて速さは非常に重要なのです。なので必然的に以下のようなテストはUTではありません。

  • DBにアクセスするテスト
  • ファイルを操作するテスト
  • APIやNWの外部依存があるテスト

特にDBに関してはUTとして取り扱う場合も多いのですが、上記の定義より
遅すぎるのでUTではない」
となります。ただ、勘違いして欲しく無いのは別に 「これらのテストが無意味である」 と言ってるわけでは無いのです。効果があるところは積極的にJUnitなどで自動化するべき部分だと思います。ただし、ビルドのたびに実行されるUTに混ぜるのは辞めよう、という話です。内容的にもモジュール単体で動いてるわけではないのでUTというよりはITが妥当ですしね。この定義はとてもシンプルでかつ実践的なので採用しやすいと思います。私は前者をQuick Test, 後者をSlow Testと呼ぶこともあります。

開発者テストという考え方

Quick TestとSlow Testは実行タイミングなどが色々違うのですが、全部UTとして扱われがちな背景について考えてみました。UTやITといった名称は本来は「何をテストするか」で区分される用語です。良く見るV字のこれとか。

ref: https://gokushiteki-softtest.hatenablog.com/entry/2017/08/14/151927

ただ現場としては 「開発フェーズで開発環境/ローカルで実行するテストがUT」 という感じで工程に紐づけて運用される事の方が多いのではないでしょうか? 単体テストは開発の一部として扱って、それが終わったらテスト環境にデプロイして実行するのが統合テスト、みたいな。なので用語に落とせば良いのでは、と個人的には下記のように呼びます。

本当はオレオレ定義なんて使わずに適切なFWやスタンダードに則るべきなのですが良いのが見当たらなかったので何かお勧めのものがあればコメント等頂けると助かります。

ここでは開発者がやるテスト、具体的にはテスト環境にデプロイする前、あるいはCD環境ならばPush前のテストを開発者テスト と呼んでいます。これは開発工程の一部で、ビルド毎に実行される高速なユニットテストと、PushのタイミングやPR時にCIで自動で流れる遅い自動テストとしてSlowTestを分けています。テスト環境に乗せた以降のテストがQAテストでこれは自動だったり手動だったりする所謂テスト工程のテスト、ですね。この定義によってUTとそうじゃない区分を作りつつ、開発者テストとしてプロジェクト上で一つの塊として扱えるようにしています。

また、SlowTestはMavenの場合はIntegration Testフェーズを使う事で簡単にUTと分離できます。
こちらの記事にあるようにJUnitのCategoryなどで簡単に分ける事が出来ます。実務的にはパッケージも別にしておく方が分かりやすいでしょう。
https://irof.hateblo.jp/entry/20130411/p1

ただ、この分類方法は使い勝手が良いと思っているのですが、実は名前がかなり良く無くいです。私はSlowTestを機能テストと呼んでる事もあるんですが、別にブラックボックステストの意味で機能テストという用語を使ってないのですよね。元々周りで単体テストの後工程で機能テストを入れることがあったのでその用語を深く考えずに拝借してしまった。。。本当は一種の統合テストだと思うけどそれだとQAテストでの統合テストと違いが分からないので便宜上このままに。私が知らないだけで良い用語がある気がするので誰か教えて下さい。てかJSTQB/ISTQBでもITはITa(顧客向けWebサイトとかシステム単独の統合テスト)とITb(バックオフィスや基幹システムを含めた統合テスト)すら分けられてない気がするし、ITをもっと細分化した一言で言える定義が欲しい。。。もっとちゃんと読めば書いてあるかもなぁ。

ユニットテストをどう書けば良いか分かりません

さて、ではユニットテストを実際に書きましょう! と言ってもどう書けばいいか分からないですよね?
例えばユニットテストはポータブルで繰り返し実行できるように書かないといけません。そうしないと他のUTの実行自体を阻害してしまいますからね。そういった良いユニットテストを書くための基本的な7つのルールを下記の記事にまとめているのでぜひ読んでみてください。

https://zenn.dev/koduki/articles/106012fdd422fa67eae6

そして、実際に上記のルールをどう守るのか? という観点でのコードサンプルを下記にまとめています。はじめの一歩として参考になると思います。

https://qiita.com/koduki/items/4fde43b68fe450c6a5d8

カバレッジ100%は目指すべきですよね?

カバレッジはテストの充実度を目指す上で良い指標だとはお思いますが100%にしたらバグが無くなるわけでもないですし、コントローラ部分など効果が薄い部分もあるのでUTで100%を目指すのはコスパはあまり良くない方針だと思います。例えばMVCモデルで実装されていればテストするべきロジックはモデルであり、コントローラは基本的にモデルを呼んだりブラウザとのやり取りをしてるのでFW自体を実装してるようなケースを除けばモック等で無理やりUTをする意味は薄いです。他にもレガシーをメンテするケースで良くありますが実際には来ない経路というのも存在します。なのでKPIとかにはせずにテストケースがどこを通ったのかを可視化するツールにとどめるのが良いと思います。新規ロジックをTDD的に作ればおのずとそこは満たせるでしょうし。

私のコードは巨大で複雑でとてもユニットテストを入れれません

「OK, 新規コードで関数単位に綺麗に書く方法は分かった。でも、うちのコードは秘伝のソースでな。。。」

ええ、分かります。千のフィールド変数と万のクラスファイル、メソッドは数百行に渡り、もちろんユニットテストは無い。そんなレガシーコードと向き合ってるのですね?

このようなレガシコードの各メソッドにUTを後から追加するのは本当に困難です。そんなあなたに是非読んで欲しい本が「レガシーコード改善ガイド」です。こういったユニットテストの無いレガシーコードにどのようにユニットテストを追加していくのか、というテクニックが記載されています。

その中でも最も基本的なスプラウトメソッド(Sprout Method)に関して紹介します。これは端的に言えば修正を加えたい時にその関数の中に直接作るのでは無く、新規のメソッドを作りそちらにロジックを追加する方法です。これにより直接編集するよりも見通しが良くなりUTも新規コードに対して実施する事が出来ます。

例えば以下のようなコードがあったとします。なお、複数の条件を満たしたときにflagの値がどんどん上書きされていくのは仕様です。。。

public void f(){
    if (foo == 123){
        flag = 3
    } else {
        flag = 0
    }

    if (bar == 456 & foo == 123){
        flag = 31
    } else {
        flag = 1
    }
}

ここに新規のロジックとしてhogeが123の時にflagが1そうでなければ0を返す処理をこの条件分岐の間に入れると以下のようになりますよね?

public void f(){
    if (foo == 123){
        flag = 3
    } else {
        flag = 0
    }

    if (hoge == 123){
        flag = 1
    } else {
        flag = 0
    }

    if (bar == 456 & foo == 123){
        flag = 31
    } else {
        flag = 1
    }
}

これを以下のようにselectHogeを作って新規コードはそちらに書きます。

public void f(){
    if (foo == 123){
        flag = 3
    } else {
        flag = 0
    }

    flag = selectHoge(hoge)

    if (bar == 456 & foo == 123){
        flag = 31
    } else {
        flag = 1
    }
}

int selectHoge(hoge){
    if (hoge == 123){
        return = 1
    } else {
        return = 0
    }
}

とても単純ですね。でもこれによってselectHogeはUTが出来ますし、メソッドの巨大化にも歯止めを付けることができ、メソッド化した事で意味の纏まりが分かりやすくなったりJavadocが書けるようになったりします。小さことですが 「千里の道も一歩から」 というやつですね。

なお、このスプラウトメソッドの最も気に入ってる点は 「既存コードを変更しないこと」 です。おそらくこのようなレガシーコードを触ってる場合だ 「動いてる既存コードに無闇に触るな」 という不文律がきっとあります。というか現実問題として修正したらテストしきるのが大変です。なので、既存を含めて守るというのは諦めて、あくまで新規コードだけでもちゃんとするというのはとても実践的な方法だと私は思います。今日から始めれるし!

そうして実績が溜まってから、徐々に既存コードにも手を付けたり大規模に実施する予算をゲットしたりという事ですね。

まとめ

ユニットテストをどのように書いていくのかをまとめてみました。正直に言って何度も入れるのに失敗した事があり、一応そのあたりを踏まえてどのようにしていくか、を書いたつもりです。もちろん、どの方法ならうまくいくってのは最終的に人と環境によるので正解は無いでしょうけど、何かの参考になれば。定着したら定着したで次の課題はあるのですが、まずはUTが定着しないとですしね。。

それではHappy Hacking!

Discussion