8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Object MotherパターンとTest Data Builderパターン

Posted at

はじめに

背景と目的

テストフィクスチャのセットアップは面倒で大変です。特に、複雑なオブジェクト構造のテストデータを準備するのは手間がかかる上に、冗長な記述によってテストコードのノイズが大きくなり可読性の低下を招きます。
本稿では、テストフィクスチャのセットアップのための代表的なデザインパターンであるObject MotherパターンTest Data Builderパターンを説明し、また、改良のためのアイデアをご紹介します。

サンプルコード

Java+JUnit5のサンプルコードをGitHubで公開しています。

テストフィクスチャとは

繰り返してテストを実行した際に同じ結果が得られることを保証するための一定の状態(環境やデータ)のことをテストフィクスチャと呼びます。本投稿はその中でも、テスト実行に必要なデータのセットアップに焦点を当てます。

サンプル

説明用のサンプルとして、図書管理アプリケーションを想定します。図書貸出管理サービスLoanServiceをテスト対象(SUT:System Under Test)とし、貸出時のチェックを行うcheckメソッドを検証する以下のテストコードがあったとします。

SimpleLoanServiceTest.java
public class SimpleLoanServiceTest {

    private static final LocalDate TODAY = LocalDate.now();
    private static final LocalDate YESTERDAY = TODAY.minusDays(1);
    private static final LocalDate A_WEEK_AGO = TODAY.minusDays(7);

    private LoanService sut;

    @BeforeEach
    void setup() {
        sut = new LoanService(TODAY);
    }
...
    @Test
    void 最大貸出数を超過する場合は貸出不可となる() {
        // Arrange
        Book book1 = new Book("枕草子");
        Loan loan1 = new Loan(book1, A_WEEK_AGO, TODAY);
        Book book2 = new Book("源氏物語");
        Loan loan2 = new Loan(book2, A_WEEK_AGO, TODAY);

        Loans loans = Loans.of(loan1, loan2);

        User user = new User("山本", TODAY, loans);

        Book[] books = new Book[]{new Book("竹取物語"), new Book("平家物語")};

        // Act
        LoanCheckResult result = sut.check(user, books);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
    }

    @Test
    void 貸出中のものに返却期限切れがある場合は新たな貸出は不可となる() {
        // Arrange
        Book book1 = new Book("枕草子");
        Loan loan1 = new Loan(book1, A_WEEK_AGO, TODAY);
        Book book2 = new Book("源氏物語");
        Loan loan2 = new Loan(book2, A_WEEK_AGO, YESTERDAY);

        Loans loans = Loans.of(loan1, loan2);

        User user = new User("山本", TODAY, loans);

        Book book = new Book("竹取物語");

        // Act
        LoanCheckResult result = sut.check(user, book);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("UNRETURNED_BOOKS"));
    }
...

上記のテストコードには以下の問題点があります。

  • Arrange節でテストデータをセットアップするコードが冗長かつテストケース間で重複している。
    • 実際のプロダクションコードではもっと多くのオブジェクト、プロパティのセットアップが必要。
  • テストケースとは無関係な値がノイズとなってテストの意図が汲み取りにくい。
    • 例えば、本のタイトル枕草子や日付定数TODAYがそのテストで意味のある値なのか単なるプレースホルダなのかわからない。

これらの問題点をどのように解決すればよいでしょうか。

Object Motherパターン

単純にコードの重複を排除するということであれば、テストデータのセットアップ処理をテストクラス内にprivateメソッドとして切り出す手もありますが、複数のテストクラスに同じようなセットアップコードが重複して記述されてしまう危険性があります。
テストデータをセットアップするファクトリメソッドを一箇所に集めたのがObject Motherです。
以下はUserをセットアップするファクトリメソッドを提供するObject Motherです。

UserMother.java
public class UserMother {

    public static User aUserWithoutLoans() {
        Loans loans = Loans.empty();
        return new User("USER", LocalDate.MAX, loans);
    }

    public static User aUserBorrowing2Books() {
        Loans loans = Loans.of(aLoan(), aLoan());
        return new User("USER", LocalDate.MAX, loans);
    }

    public static User aUserBorrowing3Books() {
        Loans loans = Loans.of(aLoan(), aLoan(), aLoan());
        return new User("USER", LocalDate.MAX, loans);
    }
...

テストケースは以下のようになります。

ObjectMotherLoanServiceTest.java
    @Test
    void 最大貸出数を超過する場合は貸出不可となる() {
        // Arrange
        User user = UserMother.aUserBorrowing2Books();

        Book[] books = BookMother.books(2);

        // Act
        LoanCheckResult result = sut.check(user, books);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
    }

Object Motherの特長として、ファクトリメソッドを一箇所にまとめることでテストクラスやテストケースをまたがっての再利用を促進する点が挙げられますが、最大の効用はデータのパターンに名前付けを行うことだと考えます。
適切な名前付けを行うことで、テストコードの可読性を高めることができます。例えば、

User user = UserMother.aUserBorrowing2Books();

というコードからはユーザー名や本のタイトル、日付といった(そのテストケースでは)ノイズとなる情報が排除され、「2冊の本を借りている任意のユーザー」というテスト条件が一目瞭然に読み取れるようになります。
一方で、Object Motherには以下のデメリットもあります。

  • ファクトリメソッドが乱立し、神クラス(God Class)になりがち。
  • 複数のテストがObject Motherに依存するので、ファクトリメソッドを修正した場合に影響が広範に及ぶ。

Test Data Builderパターン

Test Data BuilderはGoFのデザインパターンのひとつであるBuilderパターンを使ってテストデータをセットする手法です。
例を見ましょう。

UserBuilder.java
public class UserBuilder {

    private String name = "DEFAULT_NAME";
    private LocalDate expirationDate = LocalDate.MAX;
    private Loans loans = Loans.empty();

    public UserBuilder withLoans(Loans loans) {
        this.loans = loans;
        return this;
    }

    public UserBuilder withExpirationDate(LocalDate expirationDate) {
        this.expirationDate = expirationDate;
        return this;
    }

    public User build() {
        return new User(name, expirationDate, loans);
    }
}

テストコードは以下のようになります。

TestDataBuilderLoanServiceTest.java
    @Test
    void 貸出中のものに返却期限切れがある場合は新たな貸出は不可となる() {
        // Arrange
        Loan loan1 = new LoanBuilder().build();
        Loan loan2 = new LoanBuilder().withDueDate(YESTERDAY).build();

        Loans loans = Loans.of(loan1, loan2);

        User user = new UserBuilder().withLoans(loans).build();

        Book book = new BookBuilder().build();

        // Act
        LoanCheckResult result = sut.check(user, book);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("UNRETURNED_BOOKS"));

    }

一般にTest Data Builderは各プロパティのデフォルト値を定義しておき、デフォルト値を使ったオブジェクト生成は以下のようにBuilderを生成してbuildメソッドを呼び出すだけです。

new LoanBuilder().build();

デフォルト値から変更したいプロパティがあれば、Builderを生成した後にメソッドチェーンでつなげて記述して、最後にbuildメソッドを呼び出します。

User user = new UserBuilder().withLoans(loans).build();

Test Data Builderはデフォルト値の利用や、メソッドチェーンを使った流暢なAPIによってテストコードの見通しをよくしますが、以下の欠点もあります。

  • 再利用性はあまり高くない。
  • (最初のテストコードに比べると改善されてはいるが)テストコードの冗長さは残り、テストの意図が伝わりづらい。

Test Data Builderの改良

Test Data Builderをベースとしつつ、その欠点を改善するテクニックを紹介します。
まず、new演算子でのオブジェクト生成はノイズなので、コンストラクタはprivateに変更し代わりにstaticなファクトリメソッドを用意します。

UserBuilder.java
    private UserBuilder() {}

    public static UserBuilder ofDefault() {
        return new UserBuilder();
    }

デフォルト値以外で、よく使用されるセットアップのパターンもファクトリメソッドで提供します。

UserBuilder.java
    public static UserBuilder borrowing(int numOfBooks) {
        UserBuilder builder = new UserBuilder();
        List<Loan> loanList = new ArrayList<>();
        for (int i = 0; i < numOfBooks; i++) {
            Loan loan = LoanBuilder.ofDefault().build();
            loanList.add(loan);
        }
        builder.loans = Loans.of(loanList.toArray(new Loan[0]));
        return builder;
    }

このファクトリメソッドを利用してテストコードを記述すると、コードがすっきりするとともに、ファクトリメソッドの名前付けによって意図が明確になる効果があります。いわばDSL(ドメイン固有言語)を設計するようなものです。

HybridLoanServiceTest.java
    @Test
    void 最大貸出数を超過する場合は貸出不可となる() {
        // Arrange
        User user = UserBuilder.borrowing(2).build();

        Book[] books = BookBuilder.ofDefault().build(2);

        // Act
        LoanCheckResult result = sut.check(user, books);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("LIMIT_EXCEEDED"));
    }

また、ネストされたオブジェクトのセットアップを柔軟にできるようにするために、コールバックを受け取るテクニックもあります。
以下のサンプルは、UserオブジェクトにネストされているLoanオブジェクトを、LoanBuilderを使ってセットアップするコールバックを受け取る例です。

UserBuilder.java
   public UserBuilder borrowing(Consumer<LoanBuilder> callback) {
        LoanBuilder loanBuilder = LoanBuilder.ofDefault();
        callback.accept(loanBuilder);
        loans.add(loanBuilder.build());
        return this;
    }

UserBuilder側でLoanBuilderオブジェクトを生成し、受け取ったコールバックに貸し出すことから、このような実装パターンはLoanパターンと呼ばれます。※Loanという言葉が符合したのは、偶然です(^_^;)
利用するテストコードサンプルは以下です。コールバックはラムダ式で記述します。

HybridLoanServiceTest.java
    @Test
    void 貸出中のものに返却期限切れがある場合は新たな貸出は不可となる() {
        // Arrange
        User user = UserBuilder.ofDefault()
                    .borrowing(loanBuilder -> loanBuilder.noop())
                    .borrowing(loanBuilder -> loanBuilder.withDueDate(YESTERDAY))
                    .build();

        Book book = BookBuilder.ofDefault().build();

        // Act
        LoanCheckResult result = sut.check(user, book);

        // Assert
        assertThat(result.hasError, is(true));
        assertThat(result.errorCode, is("UNRETURNED_BOOKS"));
    }

まとめ

  • 複雑なオブジェクト構造のテストデータのセットアップは冗長になりがちで、ベタに書いてしまうとテストコードの可読性が下がってしまいます。
  • Object MotherTest Data Builderといったテストフィクスチャのセットアップに関わるパターンを活用し、テストコードをクリーンに保ちましょう。
8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?