先日テスト駆動な開発をして良い体験をした話

5 min read読了の目安(約5100字

はじめに

こんにちは、よしでぶです。

先日私の所属する開発チームでテスト駆動開発、いわゆるTDDというものを見よう見まねで実践してみました。

その時に感じたことについて共有したいと思います。

下記に続くコードは実際のものとは異なり簡略化しています。動作保証はしませんのでご了承ください。

経緯

前提としてチームでテスト開発駆動を経験した者はいませんでした。

現在チームの輪読会で『レガシーコードからの脱却』という本を読んでいるのですが、そこに「まずテストを書く」という章があり、とても勉強になったので

「次のコーディングで試しにテスト駆動でやってみよう」

ということでやってみた、という経緯になります。

https://www.oreilly.co.jp/books/9784873118864/

事例:ページネーション機能の実装

今回取り組んだものはページネーション機能の実装でした。全件分のリスト、何ページ毎に表示するかを示すlimit、何ページ目かを示すpageを引数にページングされた結果を返すメソッド makePagenationInfo を実装する必要がありました。

class Model {
    // id
    String id;
}

class PagenationInfo {
    // ページネーションされたリスト
    List<Model> list;
    // ページネーションされる前の全件分リストのサイズ
    int totalCount;
    // 現在のページ
    int page;
    // 最後のページ数
    int lastPage;
}

PagenationInfo makePagenationInfo(List<Model> list, int limit, int page);

やったこと

1. まず何もしない(できない)コードを書く

class Model {
    // id
    String id;
}

class PagenationInfo {
    List<Model> list;
    int totalCount;
    int page;
    int lastPage;
}

interface Helper {
    PagenationInfo makePagenationInfo(List<Model> list, int limit, int page);
}

class HelperImpl implements Helper {

    @Override
    public PagenationInfo makePagenationInfo(List<Model> list, int limit, int page) {
        return null;
    }
}

早速実装に取り掛かりたいところですが、 ぐっと我慢。 これがTDDの最初の関門かもしれません。。。

とりあえずビルドが通るように最低限のコードを書きます。ここでは makePagenationList はただnullを返すだけにします。

2. テストコードを書く

@Test
void 正常系_全件分リストの数がlimitより小さい() {
    // id=1~50までのダミーリストを作成
    List<Model> list = Util.makeDummyList(50);
    int limit = 30;
    int page = 1;
    
    PagenationInfo result = helper.makePagenationInfo(list, limt, page);
    
    // 検証
    assertEquals(30, result.getList().size());  
    assertEquals("1", result.getList().get(0).getId());
    assertEquals("30", result.getList().get(result.getList().size() - 1).getId());
    assertEquals(50, result.getTotalCount());
    assertEquals(1, result.getPage());
    assertEquals(2, result.getLastPage());
}

@Test
void 正常系_指定のpageに該当リストがない() {
    // id=1~50までのダミーリストを作成
    List<Model> list = Util.makeDummyList(50);
    int limit = 30;
    int page = 10;
    
    PagenationInfo result = helper.makePagenationInfo(list, limt, page);
    
    // 検証
    assertEquals(0, result.getList().size());  
    assertEquals(50, result.getTotalCount());
    assertEquals(10, result.getPage());
    assertEquals(2, result.getLastPage());
}

()

上記はごく一部のテスト内容です。Util.makeDummyList はidが1~50のModelインスタンスのリストを返すメソッドです。

3. とりあえずテストを回して全部NGになることを眺める

当然ですが、実装をきちんとしてないのでテストは全部コケます。

しかし、これで良いです。むしろここから全部OKになるよう実装していくぞ!という気持ちになりましょう。

4. テストが全てOKになるように実装していく。

ここまできてようやく実装をしていきます。

実装の目的はテストを通すことです。目的にそぐわない機能を実装することは控えるようにします。

class HelperImpl implements Helper {
    public PagenationInfo makePagenationInfo(List<Model> list, int limit, int page) {
        int skip = (page - 1) * limit;

        List<Model> pagenationList = list;
        int lastPage = 1;
        if (limit > 0) {
            collectedList = list.stream().skip(skip).limit(limit).collect(Collectors.toList());
            lastPage = ( list.size() + limit - 1 ) / limit;
        }
        
        return new PagenationInfo(pagenationList, list.size(), page, lastPage);
    }
}

テストが全て通れば実装は完了です。お疲れ様でした。

感想

テストコードがそのまま仕様書になる感覚

この感覚には驚きました。一般的にソフトウェア開発は

  1. 要件定義(仕様書作ったりする)
  2. 開発(実装する)
  3. テスト(ちゃんと開発できたか検証する)

という順番で行われることが多いかと思います。仕様書を書く作業は時間がかかる上、バージョン管理がしづらいため、コードと仕様書に齟齬が生じやすい経験を何度もしたことがあります。

しかし、今回はテストコードを先に書きました。こうすることで、 他のチームメンバーがどんな仕様かコード上でわかる状態 を作ることができたと感じました。

例えば、以下のテストコードでは、

全件で50個の要素数のリストのうち、1ページあたり30個で1ページ目の情報が欲しい時、全部で2ページ分あるが1ページ目の情報を返し、それは1番目~30番目の要素を返す

という仕様であることを表しています。

@Test
void 正常系_全件分リストの数がlimitより小さい() {
    // id=1~50までのダミーリストを作成
    List<Model> list = Util.makeDummyList(50);
    int limit = 30;
    int page = 1;
    
    PagenationInfo result = helper.makePagenationInfo(list, limt, page);
    
    // 検証
    assertEquals(30, result.getList().size());  
    assertEquals("1", result.getList().get(0).getId());
    assertEquals("30", result.getList().get(result.getList().size() - 1).getId());
    assertEquals(50, result.getTotalCount());
    assertEquals(1, result.getPage());
    assertEquals(2, result.getLastPage());
}

実際、今回テストコードは私が書いたのですが、makePagenationInfo メソッドを実装したのは別のチームメンバーの人でした。私だけが仕様を知っていたのにも関わらず それを口頭で伝えずとも他の人が実装できるなんて、私には衝撃的な体験でした。

テストが通れば自ずと正しく動くものができたと胸を張って言える感覚

もし、テストよりも先に実装をしていたら「これで全て満たせた機能になっているかな?」「抜け漏れはないかな?」と不安に思うことがあります。

しかし、今回はテストが全て通った時は自信を持って 実装が完了した と言える感覚がありました。 これは、実装の目的が 機能を実現させる という曖昧なものから テストを全て通す という明確なものに置き換わったからだと思いました。

一抹の不安も残るが。。。

「実装が完了した」「安心した」と言いましたが、正確には 予め書いたテストが通る実装ができた だけであって、実現したい機能が完成した わけではないことは理解しなければならないと思いました。

テストコード自体が間違っていれば実現したい機能が完成していないかもしれません。

この不安はあるものの、先述したようにテストコード=仕様書と捉えると、実現したい機能が完成していないのも納得できます。 最初の要件定義書が間違っているのだから、それを見て書いたコードはもちろん間違ったものが出来上がりますよね。

結局仕様書が間違ってる可能性があることを考えると、バージョン管理が容易なテストコードをきちんと書くほうが全体の開発効率が上がるのではないかと感じました。

まとめ

今回はテストコードを先に書いてから実装するという開発フローを初めて試してみたので思ったことをまとめました。

実際にやってみて、テスト駆動の開発は良いことが多いという印象でした。さらにテスト駆動な開発を経験してみて、チームで溜まったナレッジをこのように共有できれば良いなと思います。

ではでは。