書籍と動画から学ぶTDD

ここ1ヶ月くらいTDDについて学んでいます。

改めて書籍を読んだりt-wadaさんの動画を観たりすると、TDDは自分が思っていたよりも全然奥が深くて様々な発見がありました。

この記事では、書籍や動画から学んだことの中で特に大事だと思ったことをまとめます。

最初にわざと失敗し、初歩的なミスを潰す

TDDで最初にテストを書くとき、「必ず失敗するテスト」を書きます。

class FizzBuzzTest {
  @Test
  void test() {
    fail("これは失敗するはず");
  }
}

この時点で これは失敗するはず 以外の理由でテストが落下したとすると、それはテスティングフレームワークが機能していないことになります。

これから大量にテストを書いていくのであれば、テスティングフレームワークのセットアップミスなどは最初に潰すべき問題です。

こういったテストを書く前に潰しておくべき問題をあぶり出すために、わざとテストを失敗させるという手順が有効です。

テストから書くことで「使う側の視点」に立てる

まだ存在しないクラスやメソッドに対して最初にテストを書くと、そのメソッドを使う側の視点に立てます。

テストを書くときに想像するのは、用途に合った完璧なインターフェースだ。外部から見た振る舞いはどのようなものであるかを想像しよう。
テスト駆動開発 P.4

使う側の視点に立ち設計を考えることができるので、「作りやすさ」よりも「使いやすさ」を優先したインターフェースを考える機会を提供します。

🙅‍♂️ hogehogeメソッドなんて欲しくない

FizzBuzz fizzbuzz = new FizzBuzz();
String actual = fizzbuzz.hogehoge(1);
assertEquals("1", actual);

🙆‍♂️ convertメソッドにしよう

FizzBuzz fizzbuzz = new FizzBuzz();
String actual = fizzbuzz.convert(1);
assertEquals("1", actual);

TDDは設計を駆動する開発手法ともいえます。

手が止まったら抽象度を行き来する

TDDを実装を始める前に、やることをTODOリストに書き出します。

- [ ] 数を文字列に変換する
class FizzBuzzTest {
  @Test
  void 数を文字列に変換する() throws Exception {

    // テストを書こうとして手が止まる..
    // 具体的に何をやれば良いか分からない
    assertEquals(expected, actual);
  }
}

TODOが抽象的な場合、実装で手が止まることがあります。そういった場合は、TODOをより具体化し入力と出力をはっきりさせます。これであれば淀みなく手を動かすことができます。

- [ ] 数を文字列に変換する
  - [ ] 1を文字列1に変換する

テストコードのテスト

テストを書いたら、そのテストを成功させる最もシンプルな実装をします。

class FizzBuzzTest {
  @Test
  void _1を渡すと文字列1を返す() throws Exception {
    FizzBuzz fizzbuzz = new FizzBuzz();
    String actual = fizzbuzz.convert(1);
    assertEquals("1", actual);
  }
}
// 作り込み過ぎない
public class FizzBuzz {
  public String convert(int i) {
    return "1";
  }
}

こういったテストを通すために書くリファクタ前の実装を「仮実装」と呼びます。仮実装は正しく動くことを疑いようがないほど、シンプルにします。

仮実装の段階でもテストを走らせます。疑いようがないほどシンプルな実装をテストする目的は、「テストコードのテスト」のためです。

プロダクションコードは疑いようがないため、この時点でテストが落ちたとするとその原因はテストコードにあります。
この時点でテストが期待通りにグリーンを返すことを確認することで、「テストコードのテスト」という厄介な問題に対処できます。

アサーションルーレット

1つのテストメソッドの中に複数のアサートを並べるアンチパターンを、アサーションルーレットと呼びます。

class FizzBuzzTest {
  @Test
  void _1を渡すと文字列1を返す() throws Exception {
    FizzBuzz fizzbuzz = new FizzBuzz();

    // この行で失敗したとすると...
    assertEquals("2", fizzbuzz.convert(2));

    // この行は実行されない
    assertEquals("1", fizzbuzz.convert(1));
  }
}

一般的なテスティングフレームワークではアサートが失敗した時点以降のアサートが実行されません。アサートをテストメソッド内に縦に並べると、以下のことが起きます。

  • 失敗したとき途中までしかテストできていない状態になる
  • 失敗したときデバッグが必要になる
  • 色々なことをやり過ぎていて、動作するドキュメントとしての可読性が落ちる

動作するドキュメントとしてのテストコードにする

TDDのために具体的で実装しやすいテストコードは、仕様を表現するものに書き換える必要があります。

🙅‍♂️ 具体的でTDDしやすかったが、テストから仕様が読み取れない

class FizzBuzzTest {
  @Test
  void _1を渡すと文字列1を返す() throws Exception {
    // ...
  }

  @Test
  void _3を渡すとFizzを返す() throws Exception {
    // ...
  }
}

テスティングフレームワークが用意しているツリー構造の機能を使い、TODOに書き出した仕様をうまくテストに反映させます。テストには将来の自分も含めた読み手がいるので、これも重要なステップです。

最後に

まだまだTDDを実践し始めて間もないので、少しずつ上達していきたいです。

以下の動画はTDDを実践しつつ、Gitのコミットをどうするかについて話されている動画で、こちらも面白かったです。
僕はこの動画を参考にGitコマンドのaliasを設定したりしました。

TDD with git. Long live engineering. / Koichi ITO - YouTube