2022年1月12日

Continuous Delivery

テスト用のビルダーファクトリーパターン – テストを壊さずコードを進化させよ

ビルダーファクトリーパターンは、テストの記述と保守が、手動のテストよりも確実で簡単になるように支援します。私たちがどう活用したかをご覧ください。

05.-Design_Blog-header-4-1920w.webp

中規模から大規模のコードベースでは、テストを書くことが重要です。しかし、テストにはメンテナンスコストがかかります。書くのも維持するのも大変なテストがあると、開発者はテストを書かなくなったり、自分のPRをマージしてもらうために必要な最低限のテストしか書かなくなったりします。厳密なコードレビューやコードカバレッジツールなどを追加してみても、実際の問題を解決できるわけではありません。それどころか、テストがない状態よりも悪い状態に陥ってしまう可能性すらあります。

全員にテストを書くことを奨励する最善の方法は、短期的でも(現在の開発サイクルでも)、テストを書いてメンテナンスすることが手動テストより簡単だと証明することです。

この投稿では、私たちがテストの作成とメンテナンスを容易にした方法の一つをご紹介します。言語としてJava、Builderの生成にLombok、依存性の注入にGuiceを使用しています。

問題点 

どのテストも以下の3つのステップを経ています。

  1. テストセットアップ
  2. コール試験方法
  3. アサーション

大きな課題の一つは、テストのセットアップです。テストの設定は、書きやすく、読みやすく、保守しやすいものであることが望まれます。

さらに、良いコードベースの特性は、常に物事を整理し、リファクタリングすることができることです。しかし、もしテストをセットアップする良い方法がなければ、あるサービスやPOJO(Plain Old Java Object)のリファクタリングや改良のたびに、多くのテストのセットアップが崩れてしまうでしょう。そのため、リファクタリングにより大きな手間がかかってしまい、実施する頻度も低くなります。

他のサービスや他のJavaオブジェクト(DTO、Bean、ORMエンティティーなど)の存在に依存するサービスメソッドをテストしようとする場合、これらの有効なサービスやBeanを全て作らなければならないため、テストのための設定を記述することが難しくなります。

一方、Javaオブジェクトを共有し、オブジェクトを作成するヘルパークラスを作成する場合、テストに基づいてプロパティをオーバーライドすることが難しくなります。

そのために役立つのが、次の4つのポイントです。

  1. 理想的には、オブジェクトはデフォルトで不変であるべきで、アンチパターンでテストのためだけにセッターを使用してオブジェクトの状態を変更する必要があります。
  2. インクリメンタルな検証やオブジェクトを進化させる機能を、既存の多数のテストを壊さないで追加できるべきです。
  3. セッターを使用している場合、より多くのチェックと厳密な検証を追加して進化させることは困難となります。
  4. 新しいフィールドを追加すると、いくつかの必須プロパティが存在しない場合、テストが中断されます。

ビルダーファクトリーパターン 

上記の問題を解決する1つの方法は、ビルダーのファクトリー(デフォルトのオブジェクトでビルダーを作成する)を使用し、テストに必要なプロパティをオーバーライドできるようにすることです。

これにより、デフォルトのオブジェクトを共有しつつ、 テストでプロパティをオーバーライドするための特定のロジックを持つことができ、 両者の長所を活かすことができます。

例を見てみましょう。以下のプロパティを持つ VerifyStepTask クラスがあります。accountId、orgIdentifier、projectIdentifier など、複数のオブジェクトで共有される共通プロパティを持っていることに注意してください。
 

@Builder 
@Value 
public static class VerifyStepTask { 
 String accountId; 
 String orgIdentifier; 
 String projectIdentifier; 
 String name; 
 String callbackId; 
 String serviceIdentifier; 
 String environmentIdentifier; 
 boolean skip; 
 Status status; 
 public enum Status { IN_PROGRESS, DONE } 
}

では、基本的なBuilderFactoryクラスを定義してみましょう。

@Value
@Builder
public static class BuilderFactory {
  @Getter @Setter(AccessLevel.PRIVATE) Clock clock;
  @Getter @Setter(AccessLevel.PRIVATE) Context context;
  public static BuilderFactory getDefault() {
    return BuilderFactory.builder()
        .clock(Clock.fixed(Instant.parse("2020-04-22T10:00:00Z"), ZoneOffset.UTC))
        .context(Context.defaultContext())
        .build();
  }
  @Value
  @Builder
  public static class Context {
    String accountId;
    String orgIdentifier;
    String projectIdentifier;
    String serviceIdentifier;
    String envIdentifier;
    public static Context defaultContext() {
      return Context.builder()
          .accountId(randomAlphabetic(20))
          .orgIdentifier(randomAlphabetic(20))
          .projectIdentifier(randomAlphabetic(20))
          .envIdentifier(randomAlphabetic(20))
          .serviceIdentifier(randomAlphabetic(20))
          .build();
    }
  }
  public VerifyStepTask.VerifyStepTaskBuilder verifyStepTaskBuilder() {
    return VerifyStepTask.builder()
        .accountId(context.getAccountId())
        .orgIdentifier(context.getOrgIdentifier())
        .projectIdentifier(context.getProjectIdentifier())
        .serviceIdentifier(context.getServiceIdentifier())
        .environmentIdentifier(context.getEnvIdentifier())
        .callbackId(generateUuid())
        .status(VerifyStepTask.Status.IN_PROGRESS)
        .skip(false);
  }
}

これは、複数の通話で共有できる基本的なパラメータを全て備えています。

Clock – テスト用の固定時計です。

Context – ユースケースに応じて、異なるビルダー間で共有可能なContextを定義します。この場合、Context は accountId, orgIdentifier, projectIdentifier を持つので、全てのオブジェクトは単一のテストコンテキストに対して同じ accountId, orgIdentifier, projectIdentifier を持つことになります。このアイデアは、異なるオブジェクト間のビルダーで使用できる共通の共有プロパティをコンテキストに置くことです。

では、実際のテストでビルダーファクトリーをどう使うかを見てみましょう。
 

public class VerifyStepTaskServiceTest { 
  @Inject private VerifyStepTaskService verifyStepTaskService;  
  BuilderFactory builderFactory; 
  @Before 
  public void setup() throws IllegalAccessException { 
    builderFactory = BuilderFactory.getDefault(); 
  } 
  @Test 
  @Category(UnitTests.class) 
  public void testCreate() { 
    String activityId = generateUuid(); 
    verifyStepTaskService.create( 
    builderFactory.cvngStepTaskBuilder().activityId(activityId). callbackId(activityId).build()); 
    assertThat(verifyStepTaskService.get(activityId)).isNotNull();  
  } 
  @Test 
  @Category(UnitTests.class) 
  public void testCreate_withSkip() { 
    String callbackId = generateUuid(); 
    verifyStepTaskService.create(builderFactory.cvngStepTaskBuilder().callbackId(callbackId).skip(true).build()); 
    assertThat(verifyStepTaskService.get(callbackId)).isNotNull();  
  } 
}

これは例示のための単純なテストですが、テストが簡潔で、現在のテストロジックに関連するフィールドにのみ関係していることが分かります。しかし、将来的に新しいプロパティを追加したり、追加の検証ロジックに基づいてデフォルトビルダーを更新したりする柔軟性は保たれています。これは、より複雑なシナリオでも機能します。

例えば、VerifyStepTaskに新しい必須プロパティを追加するとします。その場合、VerifyStepTaskBuilder メソッドと、新しいプロパティのデフォルト値を取得する VerifyStepTask を使用する全てのテストを変更するだけでよいのです。

このパターンを使用するメリットを紹介します。

  1. デフォルトのオブジェクトを共有することで、セットアップをテストのための特定のプロパティの設定のみに集中させることができます。現在のテストでテストされていないオブジェクトの具体的な詳細について、テストが知る必要はない。
  2. 保守性の低いテストの大きな原因の1つは、 検証ロジックをオブジェクトに追加した際に複数のテストが壊れてしまうことです。これで、全てのテストを修正するのではなく、デフォルトのビルダーを修正するだけでよくなりました。
  3. 全ての検証ロジックを builder の build メソッド (またはコンストラクタ) に含めることができるので、本当にセッターのない immutable クラスを持つことができます。
  4. リファクタリングが容易。ほとんどの場合、デフォルトのビルダーオブジェクトを変更するだけでよいからです。
  5. デフォルトオブジェクト間で共通のコンテキストを共有することで、accountIdやprojectIdentifierなどの共通プロパティの初期化で不要な定型文を削除することができるようになります。

留意点 

  1. builder-factory は実際のオブジェクトではなく、ビルダーを返すためにのみ使用する。
  2. パラメータを要するビルダーメソッドを持たないこと。これは、ユーザーからの入力を必要としない、デフォルトで有効なビルダーオブジェクトを返すべきである - つまり、ゼロパラメータとすること。
  3. 依存するオブジェクトは、同じビルダーファクトリのデフォルトオブジェクトを使って作成する必要があります。こうすることで、デフォルトが一貫して予測可能であるため、より簡潔なテストが書けるようになります。
  4. 共通オブジェクトの識別子は全てContextの一部でなければなりません。

結論 

ユニットテストを書きやすくするためのステップを踏むことは、設計に劇的な効果をもたらします。これは、よりテストしやすいコードを書くようになったときにも言えることです。もしまだこのようなものを使っていないのであれば、コードベースのテストにビルダーファクトリーパターンを試してみてはいかがでしょうか。

関連するトピックとして、Test IntelligenceContinuous Integration Testingの記事もご覧ください。


この記事はHarness社のウェブサイトで公開されているものをDigital Stacksが日本語に訳したものです。無断複製を禁じます。原文はこちらです。

Harnessに関するお問い合わせはお気軽にお寄せください。

お問い合わせ