はじめに
背景と目的
テストフィクスチャのセットアップは面倒で大変です。特に、複雑なオブジェクト構造のテストデータを準備するのは手間がかかる上に、冗長な記述によってテストコードのノイズが大きくなり可読性の低下を招きます。
本稿では、テストフィクスチャのセットアップのための代表的なデザインパターンであるObject Motherパターン
とTest Data Builderパターン
を説明し、また、改良のためのアイデアをご紹介します。
サンプルコード
Java+JUnit5のサンプルコードをGitHubで公開しています。
テストフィクスチャとは
繰り返してテストを実行した際に同じ結果が得られることを保証するための一定の状態(環境やデータ)のことをテストフィクスチャ
と呼びます。本投稿はその中でも、テスト実行に必要なデータのセットアップに焦点を当てます。
サンプル
説明用のサンプルとして、図書管理アプリケーションを想定します。図書貸出管理サービスLoanService
をテスト対象(SUT:System Under Test)とし、貸出時のチェックを行うcheck
メソッドを検証する以下のテストコードがあったとします。
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
です。
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);
}
...
テストケースは以下のようになります。
@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パターン
を使ってテストデータをセットする手法です。
例を見ましょう。
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);
}
}
テストコードは以下のようになります。
@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なファクトリメソッドを用意します。
private UserBuilder() {}
public static UserBuilder ofDefault() {
return new UserBuilder();
}
デフォルト値以外で、よく使用されるセットアップのパターンもファクトリメソッドで提供します。
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(ドメイン固有言語)を設計するようなものです。
@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
を使ってセットアップするコールバックを受け取る例です。
public UserBuilder borrowing(Consumer<LoanBuilder> callback) {
LoanBuilder loanBuilder = LoanBuilder.ofDefault();
callback.accept(loanBuilder);
loans.add(loanBuilder.build());
return this;
}
UserBuilder
側でLoanBuilder
オブジェクトを生成し、受け取ったコールバックに貸し出すことから、このような実装パターンはLoanパターン
と呼ばれます。※Loan
という言葉が符合したのは、偶然です(^_^;)
利用するテストコードサンプルは以下です。コールバックはラムダ式で記述します。
@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 Mother
やTest Data Builder
といったテストフィクスチャのセットアップに関わるパターンを活用し、テストコードをクリーンに保ちましょう。