放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

何故テスト駆動開発を行うのか?

こんにちは。そろそろこの恥ずかしいハンドルネームから解放されたいと思っている放浪軍師です。

さて本日は、タイトルにあるように 何故テスト駆動開発を行うのか? について書いてみようと思います。前々から書いてみたかったこの話題なのですが、結構マサカリが飛びそうで怖いです。でも勇気をもって公開させていただきたいと思います。反論や訂正などがございましたらどうぞご指摘ください。暴言じゃ無い限りは歓迎致します

そもそも何故テストを書くのか?

さて、本題の前にそもそも 何故テストを書くのか? という事から抑えていきたいと思います。

コスト面から考える自動テスト

先日 X (twitter) にて、 何故手動テストの方がコストがかからない場合でも自動テストを書くんですか? というようなツイートがバズっているのを見かけました。様々な意見があるとは思いますが、私が出す答えは、そもそも手動テストはコスト激高で手動テストの方がコストがかからないなんてことはありえないので、書ける限り自動テストを書くべき になります。

テストは動かし続けてこそ意味がある

テストというのはそもそも対象がどんなに簡単な機能であったとしても、それが 1 回だけで済むなんてことは絶対にありえません。何故なら、どの作業がその機能に影響を与えるかは誰にもわからないから です。

例えば 1 + 1 の解を返すような単純なメソッドであったとしても、そのメソッドが急に想定外の動きをするという可能性は十分にありえます。たとえば 該当メソッドのあるファイルを誤って消してしまったら どうでしょう?当然対象メソッドは動かなくなりますよね。誤って消したのにそのメソッドを手動でテストしますか?しないはずです。だって誤って消したんですから。

そう、テストというのは 1 回きりでは意味がなく、何かを変更するたびにすべての機能が正しいことを確認し続けて初めて意味があります 。何かを変更するたびにすべての機能を手動でテストし続けますか?そのコストは計り知れないものになるでしょう。よって、コスト面から考えた場合でもテストは可能な限り書くべき という事になると私は考えています。

余談ですが、手動でテストしてその結果をキャプチャして Excel に貼り付けて提出みたいなのが嫌われる理由はこれです。その Excelその瞬間には正しかった(っぽい) ということの証明にしかなりません。エンジニアの心を折る以外の意味はないので本当にやめましょう。

開発面から考える自動テスト

開発側の観点としては、保守性と拡張性が高まるというのが非常に重要な役目だと考えます。

テストコードは要件そのものである

ちょっとわかりにくいと思うので例を出すと、ある機能に対するテストに、 Aならαを返す Bならβを返す という2つの単体テストを書いているとします。これはまさにこの機能が満たすべき要件そのものであり、しかもそれが非常に解りやすい形で残されることになります。例えばこんな感じ。

<?php

public function test_method(): void
{
    $hoge = new Hoge();
    $result = $hoge->method('A'); // Aなら
    $this->assertEquals('α', $result); // αを返す
    $result = $hoge->method('B'); // Bなら
    $this->assertEquals('β', $result); // βを返す
}

この解りやすい形で残されるというのが非常に重要で、例えば 1 年後この機能に Cならγを返す という要件が追加されるとしましょう。この場合、この機能の要件は、Cならγを返す だけではなく、元からあった Aならαを返す Bならβを返す も満たすになります。この際にテストコードがあれば、安全に 3 つの要件を抑えたコードが書けるはずです。

<?php

public function test_method(): void
{
    $hoge = new Hoge();
    $result = $hoge->method('A'); // Aなら
    $this->assertEquals('α', $result); // αを返す
    $result = $hoge->method('B'); // Bなら
    $this->assertEquals('β', $result); // βを返す
    // 追加するだけ
    $result = $hoge->method('C'); // Cなら
    $this->assertEquals('γ', $result); // γを返す
}

ですがもしテストコードが無かったら、1 年前に定義された元々の要件をちゃんと把握しきれずに、Cならγを返す は満たしているけど、Aならαを返す Bならβを返す が満たされないような実装をしてしまうかもしれません。もちろん実装コードから元の要件を読み取ることも可能ではあるのでしょうが、それが簡単では無いことも少なくは無いはずです。

このようにテストコードを書くということは、機能に対して要件を安全に追加できるということであり非常に大きなメリットとなります。

単体テストを書きやすくすると最終的にはクリーンアーキテクチャになる

単体テストを書く際に気をつけなければならないのは、クラス同士を祖に保つことです。たとえば、以下のような実装は避けなければなりません。

<?php

class Hoge {
     public function hoge() : string 
     {
          $fuga = new Fuga();
          return 'hoge' . $fuga->fuga();
     }
}

この場合、 hoge メソッドは Fuga クラスに依存してしまっているため、Fuga クラスに変更が入った際にもろに影響を受けてしまうし、なにより Fuga クラスがデータベースやストレージなどの外部サービスを参照している場合はその外部の状況までも再現しておく必要が出てきたりして非常に面倒です。そのため、以下のように外部から依存するクラスを注入してやる必要があります。これを Dependency Injection (DI:依存性の注入)といいます。

<?php

class Hoge {
     public function hoge(Fuga $fuga) : string 
     {
          return 'hoge' . $fuga->fuga();
     }
}

上記のやり方は、メソッドで注入していますが、コンストラクタで注入してやる方法もあります。

<?php

class Hoge {

     public function __construct(private Fuga $fuga) {
     }

     public function hoge() : string 
     {
          return 'hoge' . $this->fuga->fuga();
     }
}

ただ、これではまだ足りません。結局は外部のクラスに依存しているからです。そこで、インターフェースを使い、そちらに依存させるように書き換えます。

<?php

class Hoge {

     public function hoge(IFuga $fuga) : string 
     {
          return 'hoge' . $fuga->fuga();
     }
}

こうするとクラスではなくインターフェースに依存するので、別のクラスを用意して挙動をでっちあげる事ができるようになります。

<?php

class MockFuga implements IFuga
{
     public function fuga(): string
     {
          return 'fuga';
     }
}

public function test_hoge() : void
{
     $hoge = new Hoge();
     $fuga = new MockFuga();
     $result = $hoge->hoge($fuga);
     $this->assertEquals('hogefuga', $result);
}

こうすることで、注入されたクラスに依存する事なくテストを行う事ができます。やったね!

さて、ここで勘の良い人なら気が付きます。あれ?これって・・・クリーンアーキテクチャができ上がってね?

はい。そのとおりです。

クラス同士をインターフェースを用いて疎結合して関心を分離し、差し替えやすくするということはそれ即ちクリーンアーキテクチャです。

そう、クリーンアーキテクチャとは、単体テストを突き詰めていくと辿り着ける設計だったんだよ! (凸凸凸凸<な、なんだってー!!!)

いやまぁこれは実際は逆の話で、クリーンアーキテクチャだとテストが書きやすいというのが正しいですし、色々と要素が足りないのでちょっと強引な物言いです。ごめんなさい。しかし、テストの観点からもクリーンアーキテクチャの良さがわかるという感じの説明だと捉えていただけると幸いです。

なお、クリーンアーキテクチャに関しては @nuits_jp さんが非常にわかりやすく説明されていますので参考にされてください。

www.nuits.jp

まぁ尤も、私はクリーンアーキテクチャを厳密に守る必要はなく、インターフェースは使わずにクラスを注入して、テストではモックを使うという程度に留めて良いと考えています。依存の向きは守るべきですけどね。

<?php

public function test_hoge() : void
{
     // モックを立てて
     $fuga = Mockery::mock(Fuga::class);
     $fuga->shouldReceive('fuga')
          ->once()
          ->andReturn('fuga'); // `レスポンスを偽装して

     $hoge = new Hoge();
     $result = $hoge->hoge($fuga); // DI する
     $this->assertEquals('hogefuga', $result);
}

なんせ毎回インターフェースを切るのは超面倒ですし、今日日モックが存在しない環境というのもまぁ無いでしょう。一部の実装をまるっと差し替えるなんてことも滅多に無いことでしょうしね。その辺は割り切っても良いかなと思います。

何故テスト駆動開発を行うのか?

さて、長くなりましたがここからが本題。何故テスト駆動開発を行うのか? です。なお、テスト駆動開発というのはざっくり言うと、先にテストコードを書いた後に実装コードを書いていく開発手法 のことを指します。

テストを先に書くと作業に集中できる

機能を実装する際に考えること。それはこの機能がどのような要件を果たすべきなのかという事になるはずです。

例として、とあるメソッドを実装するとしましょう。このメソッドが満たすべき要件は、 Aならαを返すBならβを返す です。

この際、先に実装する場合ならこんな感じになるはずです。

【手順1】凸<(このメソッドの要件は、えーっと、Aならαを返すBならβを返す だよなぁ・・・実装してみよう)

<?php

class Hoge{
     public function method(string $value): string
     {
          return match ($value) {
               'a' => 'α',
               'b' => 'β',
          } 
     }
}

【手順2】凸<(よし、次はテストだな。要件は、、、そうそう、Aならαを返すBならβを返す だったわ)

<?php

public function test_method():void
{
     $hoge = new Hoge();
     $result = $hoge->method('A'); //Aなら
     $this->assertEquals('α', $result); //αを返す
     $result = $hoge->method('B'); //Bなら
     $this->assertEquals('β', $result); //βを返す
}

【手順3】凸<(完成した!)

こんな感じです。一見なんの問題ないようにも見えますね。次に、先にテストから書く場合を考えてみましょう。

【手順1】凸<(このメソッドの要件は、えーっと、Aならαを返すBならβを返す だよなぁ・・・テストを書いてみよう)

<?php

public function test_method():void
{
     $hoge = new Hoge();
     $result = $hoge->method('A'); //Aなら
     $this->assertEquals('α', $result); //αを返す
     $result = $hoge->method('B'); //Bなら
     $this->assertEquals('β', $result); //βを返す
}

【手順2】凸<(よし、次は実装だな。要件はテストに書いてある、Aならαを返すBならβを返す だね)

<?php

class Hoge{
     public function method(string $value): string
     {
          return match ($value) {
               'a' => 'α',
               'b' => 'β',
          } 
     }
}

【手順3】凸<(完成した!)

どうでしょう?違いは分かりましたでしょうか?

実装を先に行う場合であっても、要件を出さなければ実装はできません。そのため、頭の中に要件を思い描きながら実装を行う事になるので、要件出しと実装を同時に進める という並列作業になってしまいます。そうなると要件出しだけに集中するという事にはならないので、必要だった要件が抜け落ちてしまう可能性が高まります。要件が複雑になればなるほどその可能性は高くなるでしょう。また、テストコードを作成する際も頭に思い描いただけの要件をすべて正しく並べきる事ができるかは正直怪しいところです。

一方テストを先に書く場合は、初めに この機能を実装するにあたり必要な要件は何なのか? に対してだけ集中する事が可能となります。この時点では実装方法とかどうでもいいからです。そして、その要件はテストコードに記述するので、後から思い出す必要もありません。実装中はテストが通る事だけ考えて実装に集中する 事ができます。つまり、並列作業にはならないのです。

これは大変に大きな違いで、テスト駆動開発であれば、要件出しと実装それぞれに対して真摯に向き合える のです。これがテスト駆動開発の1番のメリットだと私は考えています。

どっちにせよテストを書くのですから、実装から先に行なっている方は一度このテスト駆動開発を試してみてはいかがでしょうか?

まとめ

本題の方が短いやんけ!