Spring BootでRetrofitを使ったテストをめっちゃ楽にする ContextCustomizer編

前書き

前回の記事から1ヶ月ほど経っております。 少し涼しくなって来たでしょうか。今ものすごくラーメンが食べたい齋藤です。

この記事ではSpring Bootのテストを対象として ContextCustomizerを使ってテストを楽に書けるようにしてみます。

前提

本記事では、Retrofit2を使ったテストを例にやっていきます。 使用したソースはこちらのリポジトリにおいています。

ランダムポートで立ち上げたSpring Bootのサーバに対して Retrofit2を使いAPIのテストを書きます。

なお、本記事ではAssertJを使っています。

今回用意したのは以下の3つです

  • @SpringBootApplicationをつけたクラス SampleApplication
  • @RestControllerをつけたコントローラ SampleController
  • コントローラが返すUserクラス

それぞれソースを以下に示しています。

@SpringBootApplication
public class SampleApplication {    
    public static void main(String[] args) {
        SpringApplication.run(SampleApplication.class, args);
    }
}
@RequestMapping("/user")
@RestController
public class SampleController {
    @GetMapping
    public User user() {
        return new User("test");
    }
}
@Value // lombokのアノテーション
public class User {
    private final String name;
}

また、Retrofitで使うインターフェースはこちらです。 Retrofitではコントローラと似たような感じのものを作ります。

public interface UserEndpoint {
  @GET("/user")
  public Call<User> user();
}

まずは素朴にテストを書いてみる

下記に素朴な形でテストを書いてみました。 どうでしょうか?Springマスターのあなたなら簡単でしょうか?

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserEndpointTestFirst {

  @Autowired
  WebApplicationContext wac;

  @LocalServerPort
  int port;

  Retrofit retrofit;

  @Before
  public void setUp() {
    String contextPath = wac.getEnvironment()
      .getProperty("server.context-path", "");
    retrofit = new Retrofit.Builder().baseUrl("http://localhost:" + port + contextPath)
      .addConverterFactory(JacksonConverterFactory.create())
      .build();
  }

  @Test
  public void test() throws IOException {
    UserEndpoint endpoint = retrofit.create(UserEndpoint.class);
    User user = endpoint.get()
      .execute()
      .body();
    assertThat(user).returns("test", User::getName);
  }
}

こういったテストが何個も並ぶと地獄ですね。もう少し楽にしてみましょう。 また、WebApplicationContextが強力なAPIを持っているので テストを見たときにギョッとしてしまいます。(変なことしてないよね・・・?みたいな)

もうちょっとスッキリさせて見る。

下記の形でスッキリしました!!!!!!!!!! どうでしょうか?先ほどよりかはスッキリしているように見えます。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserEndpointTestSecond extends TestBase {

  @Test
  public void test() throws IOException {
    UserEndpoint endpoint = retrofit.create(UserEndpoint.class);
    User user = endpoint.get()
      .execute()
      .body();

    assertThat(user).returns("test", User::getName);
  }
}

上記コードでは親クラスが指定されています。 特に難しいことはやっていません。@Beforeの処理を親クラスに入れただけです。 こんな感じ。

public class TestBase {
  @Autowired
  private WebApplicationContext wac;

  @LocalServerPort
  private int port;

  protected Retrofit retrofit;

  @Before
  public void setUp() {
    String contextPath = wac.getEnvironment()
      .getProperty("server.context-path", "");
    retrofit = new Retrofit.Builder().baseUrl("http://localhost:" + port + contextPath)
      .addConverterFactory(JacksonConverterFactory.create())
      .build();
  }
}

ここでも、やはりというかWebApplicationContextのAPIを呼び出したりしています。 もう少しどうにかならないものでしょうか?? テストをしたいのにも関わらず、Springの層が見えすぎている気がします。

脱線

Spring Bootの組み込みサーバを使ったテストでは TestRestTemplateを使うことができます。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserEndpointTestOther extends TestBase {

    @Autowired
    private TestRestTemplate template;

    @Test
    public void test() throws IOException {
        User user = template.getForObject("/user", User.class);
        assertThat(user).returns("test", User::getName);
    }
}

このAPIを使うの個人的に嫌な部分があって "/user"という定数が突然出てくるわけですが(いやまぁAPIのエンドポイントなんですけども) これが複数散乱することになります。 複雑なAPIになればなるほどテストも増えて、この"/user"がバラまかれる形になります。

辛くない??

ところで、TestRestTemplateはどこから来たのでしょうか? 今回の記事のミソはTestRestTemplateはどこから来たのか、がミソになります。

早速ネタバラシです

ContextCustomizerを実装したSpringBootTestContextCustomizerというクラスが spring-boot-testの中に存在します。

このクラスがTestRestTemplateをSpringのDIコンテナの中に登録しているおかげで テストクラスにおいて@Autowiredを使ってTestRestTemplateのDIができるわけです。

実装の前にテストをこんな風にしたい、というのを見てみる

実装を書くその前にどんな風にテスト書きたいかなぁと考えて見たところ 下みたいな感じで書けたら嬉しいですね。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserEndpointTest {

  @Autowired
  Retrofit retrofit;

  @Test
  public void test() throws IOException {
    UserEndpoint endpoint = retrofit.create(UserEndpoint.class);
    User user = endpoint.get()
      .execute()
      .body();

    assertThat(user).returns("test", User::getName);
  }
}

ContextCustomizerを使ってテストを楽にしてみる

ContextCustomizerを使って上記の形でテストを書けるようにしましたが 少し長くなってしまったので、ここにザッとまとめておきます。

  • 登録したいBeanのFactoryクラスを作成する。
  • ContextCustomizerで登録したいBeanを上記で作成したFactoryクラスと共に登録する
  • ContextCustomizerを生成するFactoryクラスを作成する。
  • ContextCustomizerを生成するFactoryクラスを設定ファイルに記述しておく

では実装してみます。

部分部分を抽出して見ていきます。 以下のコードの大部分は先ほど紹介したSpringBootTestContextCustomizerのコードです。 少し見やすさのために簡略化・省略しています。

SampleContextCustomizerでbeanをBeanFactoryと共に登録し TestRetrofitFactoryはSpringのEnviromentクラス等からポートなどを取り出して Retrofitオブジェクトを構築しています。

public class SampleContextCustomizer implements ContextCustomizer {

  @Override
  public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedContextConfiguration) {
    SpringBootTest annotation = AnnotatedElementUtils.getMergedAnnotation(mergedContextConfiguration.getTestClass(), SpringBootTest.class);
    if (annotation.webEnvironment()
      .isEmbedded()) {
      ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
      if (beanFactory instanceof BeanDefinitionRegistry) {
        BeanDefinitionRegistry registry = (BeanDefinitionRegistry) context;
        registry.registerBeanDefinition(Retrofit.class.getName(), new RootBeanDefinition(TestRetrofitFactory.class));
      }
    }
  }

  public static class TestRetrofitFactory implements FactoryBean<Retrofit>, ApplicationContextAware {
    // ...省略
    @Override
    public Retrofit getObject() throws Exception {
      String port = this.env.getProperty("local.server.port", "8080");
      String contextPath = this.resolver.getProperty("context-path", "");
      return new Retrofit.Builder().baseUrl((isSsl ? "https" : "http") + "://localhost:" + port + contextPath)
        .addConverterFactory(JacksonConverterFactory.create())
        .build();
    }
    // ...省略
  }
  // ...省略
}

また、これと同時に設定ファイルを追加しました。

src/test/resources/META-INF/spring.factoriesに以下の設定を書いています。 この設定ファイルはSpringFactoriesLoaderによって読み込まれます。(javadoc)

org.springframework.test.context.ContextCustomizerFactory=\
com.github.wreulicke.spring.TestContextCustomizerFactory

この設定に追加したFactoryクラスは以下のコードです。

public class SampleContextCustomizerFactory implements ContextCustomizerFactory {

  @Override
  public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
    if (AnnotatedElementUtils.findMergedAnnotation(testClass, SpringBootTest.class) != null) {
      return new SampleContextCustomizer();
    }
    return null;
  }

}

どうでしょうか。 この実装を追加することで簡単(?)にテストを書くことができるようになりました!! これで先ほど見せたこんなテストが書きたいなー!というのが動くようになります。

今回のケースでは説明しませんでしたが ライブラリとしてContextCustomizer等を切り出しておくことで 非常に簡単にRetrofitなど自分が使いたいクラスを使うことが可能になるでしょう。

また、今回ではFactoryの中にベタっと実装を書いたわけですが ContextCustomizerFactoryのcreateContextCustomizerの引数からテストクラスが取得できるので テストクラスにアノテーション等を使って外から設定を注入することができそうですね。

まとめ

いかがだったでしょうか。

今回の記事ではContextCustomizerを使ってテストを簡単に書けるようにしてみました。 ライブラリにしておくと簡単に使い回せそうですね!

非常に強力な機能ですので、皆さん使ってみてはいかがかと思います。

ソースはこちらのリポジトリにおいています。

次は似たようなクラスのTestExecutionListenerで楽にしてみたいなぁとか思うわけですが。 いつになるやら。。。

さぁラーメン食べに行くぞー!!

ぼやき

これは楽になったとは言えない。