雑記: テスト時に Bean を別の Bean に置き換えたい話

お気付きの点がありましたらご指摘いただけますと幸いです。

ベントウアプリケーション(ツナおにぎりサービス)
Spring Boot でベントウアプリケーションという REST API を実装します。例えば Spring Boot REST API の作成 - 公式サンプルコード にしたがって統合開発環境の Spring Initializr から Spring Web を選択し、src/main/java/com/example/bentou 以下に必要なクラスをすべて実装します。
RestController には以下のように OnigiriService をコンストラクタインジェクションします。

package com.example.bentou;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BentouController {
    private final OnigiriService service;

    @Autowired
    public BentouController(@Qualifier("tuna") OnigiriService service) {
        this.service = service;
    }

    @GetMapping("/bentou")
    public Onigiri provide() {
        Onigiri onigiri = this.service.provideOnigiri();
        return onigiri;
    }
}

つまりベントウアプリケーションは /bentou エンドポイントにアクセスすると OnigiriService からおにぎりを取り出して返します。面倒なのでおにぎり以外の品目はありません。面倒なのでおにぎりは単に name と guzai というメンバをもつクラスにします。ベントウアプリケーションを起動すると以下の挙動になります。

$ curl 'localhost:8080/bentou'
{"guzai":"ツナ","name":"おにぎり"}

ベントウアプリケーション(ツナおにぎりサービス)の単体テスト
単体テストも実装します。以下の単体テストでは @SpringBootTest で実際のベントウアプリケーション起動時と同様に BentouController を含むコンポーネント一式を生成させ(=アプリケーションコンテキストを開始させ)、MockMvc を利用して BentouController が受信するエンドポイントにリクエストしています(参考:Spring Boot MockMvc と @MockBean で Web レイヤーテスト - 公式サンプルコード)。

package com.example.bentou;

import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders
                .get("/bentou"))
                .andExpect(status().is(200))
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andReturn();

        String expected = "{\"name\":\"おにぎり\",\"guzai\":\"ツナ\"}";  // ツナおにぎり
        String actual = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
        JSONAssert.assertEquals(expected, actual, JSONCompareMode.STRICT);
    }
}

単体テストでは梅おにぎりサービスにしたい場合
ところで OnigiriService をインジェクトするとき @Qualifier("tuna") を指定したように、実は OnigiriService には複数の実装があります。ツナおにぎりサービスと梅おにぎりサービスがあります。

@Service
@Qualifier("tuna")
public class OnigiriServiceTuna implements OnigiriService {
@Service
@Qualifier("ume")
public class OnigiriServiceUme implements OnigiriService {

いま、本番ではツナおにぎりサービスを利用するが単体テストでは梅おにぎりサービスを利用したいとします。

梅おにぎりサービスを生成して、それで BentouController を生成して、それで MockMvc を生成する
例えば、素直に梅おにぎりサービスを生成して、それを渡して BentouController を生成して、それを渡して MockMvc を生成すれば梅おにぎりになります。

import org.springframework.test.web.servlet.setup.MockMvcBuilders;

class BentouControllerTest {
    @Test
    public void test() throws Exception {
        OnigiriService service = new OnigiriServiceUme();  // 梅おにぎりサービス
        BentouController controller = new BentouController(service);
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();

        // アクセス
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders
                .get("/bentou"))
                .andExpect(status().is(200))
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andReturn();

        String expected = "{\"name\":\"おにぎり\",\"guzai\":\"\"}";  // 梅おにぎり
        String actual = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
        JSONAssert.assertEquals(expected, actual, JSONCompareMode.STRICT);
    }
}

梅おにぎりサービスを取得して、それで BentouController を生成して、それで MockMvc を生成する
梅おにぎりサービスをテスト内で自分で生成するのではなく、アプリケーションコンテキストから取得し、それを渡して BentouController を生成し、それを渡して MockMvc を生成しても梅おにぎりになります。梅おにぎりサービスが依存性をもつ場合はこちらの方が記述が楽だと思います。

@SpringBootTest
class BentouControllerTest {
    @Autowired
    private OnigiriServiceUme service;  // 梅おにぎりサービス

    @Test
    public void test() throws Exception {
        BentouController controller = new BentouController(service);
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
        // 以下同様にアクセス

ツナおにぎりサービスをモックして、そのふるまいを梅おにぎりサービスのふるまいで置き換える
上2つの方法ではテスト内で BentouController を生成しましたが、この実装では今後 BentouController に卵焼きサービスやウインナーサービスなど追加の依存性が出てきたときメンテナンスが面倒だと思います。なのでやはり BentouController もアプリケーションコンテキストから取得するようにし、アプリケーションコンテキスト内のツナおにぎりサービスをモックインスタンスにしてそのふるまいを梅おにぎりサービスにすることを考えます。アプリケーションコンテキスト内の Bean をモックするので @MockBean を使用します。

import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @Autowired MockMvc mockMvc;
    @Autowired private OnigiriServiceUme serviceUme;
    @MockBean private OnigiriServiceTuna serviceTuna;  // モックインスタンスに置き換え

    @Test
    public void test() throws Exception {
        // モックインスタンスに梅おにぎりサービスのふるまいをさせる
        Mockito.when(serviceTuna.provideOnigiri()).thenReturn(serviceUme.provideOnigiri());
        // 以下同様にアクセス

TestConfiguration を記述して、インジェクト時の仮引数名で梅おにぎりサービスを登録する
上の方法ではツナおにぎりサービスをモック化して一々メソッドを置き換える記述をしましたが、今回は梅おにぎりサービスが目指すモックそのものなので、そもそも梅おにぎりサービスをインジェクトしてしまいたいです。なので TestConfiguration で @Qualifier("tuna") な OnigiriService として service という名前(BentouContoroller でのインジェクト時の仮引数名)で OnigiriServiceUme を登録してしまいます。これでインジェクト時にこちらの Bean を割り込ませられます。TestConfiguration はテストクラス内に静的ネストクラスとして記述すれば反映されます。
追記: この方法はクラス名とインジェクト時の仮引数名が異なる前提で、同じ Qualifier なら同名の Bean が選ばれることを利用して割り込みさせています。@Qualifier("tuna") な OnigiriService がコンテキスト内に2つ存在することになります。クラス名とインジェクト時の仮引数名が同じ場合は2つ後の方法を参照してください。

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @TestConfiguration
    public static class ConfigurationUme {
        @Bean
        @Qualifier("tuna")
        public OnigiriService service() {
            return new OnigiriServiceUme();
        };
    }

    @Autowired MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        // 以下同様にアクセス

TestConfiguration を記述して、インジェクト時の仮引数名で梅おにぎりサービスを参照する
上の方法では割り込ませる梅おにぎりサービスを改めて生成しましたが、今回はアプリケーションコンテキスト内に梅おにぎりサービスがそれはそれで存在する状況なので、そちらを参照するようにしてしまえば2つ目の梅おにぎりサービスを生成しなくて済むと思います。
追記: この方法もクラス名とインジェクト時の仮引数名が異なる前提で、同じ Qualifier なら同名の Bean が選ばれることを利用して割り込みさせています。クラス名とインジェクト時の仮引数名が同じ場合は2つ後の方法を参照してください。

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @TestConfiguration
    public static class ConfigurationUme {
        @Bean
        @Qualifier("tuna")
        public OnigiriService service(@Qualifier("ume") OnigiriService service) {
            return service;
        };
    }

    @Autowired MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        // 以下同様にアクセス

2つ目の梅おにぎりサービスが生成されていないことの確認に、以下のように Autowired してデバッグすると service と service1 が同じオブジェクトを指していることがわかると思います。

    @Autowired MockMvc mockMvc;
    @Autowired @Qualifier("tuna") OnigiriService service;
    @Autowired @Qualifier("ume") OnigiriService service1;

TestConfiguration を記述して、ツナおにぎりサービスを梅おにぎりサービスで上書きする
上2つの方法ではツナおにぎりサービスをインジェクトする箇所で梅おにぎりサービスを割り込ませしたが、そもそもテスト時にコンテキスト内のツナおにぎりサービスを上書きすることもできます。この場合は以下のプロパティを設定して上書きを許可する必要があります。

spring.main.allow-bean-definition-overriding: true

後は2つ上の方法の @Bean アノテーションにのっとるクラス名(キャメルケース)を記述するか、メソッド名の方を変更するかすればツナおにぎりサービスを上書きすることができます。

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @TestConfiguration
    public static class ConfigurationUme {
        @Bean("onigiriServiceTuna")  // ここ
        @Qualifier("tuna")
        public OnigiriService service() {  // こちらを onigiriServiceTuna() に変えてもよい
            return new OnigiriServiceUme();
        };
    }

    @Autowired MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        // 以下同様にアクセス

TestConfiguration を記述して、ツナおにぎりサービスを梅おにぎりサービスの参照で上書きする
2つ前の方法についても、割り込みではなく上書き方式でできます。上書きを許可する必要があります。

spring.main.allow-bean-definition-overriding: true

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @TestConfiguration
    public static class ConfigurationUme {
        @Bean("onigiriServiceTuna")  // ここ
        @Qualifier("tuna")
        public OnigiriService service(@Qualifier("ume") OnigiriService service) {
            return service;
        };
    }

    @Autowired MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        // 以下同様にアクセス

TestConfiguration を記述して、BentouController を梅おにぎりサービスを代入したものに上書きする
ここまでの方法ではインジェクトするツナおにぎりサービスを割り込み/上書きしましたが、「ツナおにぎりサービスのインジェクト時に割り込み/上書きするというよりは BentouController からの参照をツナおにぎりサービスから梅おにぎりサービスに付け替えたい」ということもあると思います。その場合は BentouController の方を上書きすることになると思います。依存性が増えたらメンテが要りますが、「卵焼きサービスは関東風から関西風に付け替えたい」などというように他の依存性でも付け替えをしたいときはこの方が記述がすっきりする気がします。

spring.main.allow-bean-definition-overriding: true

@SpringBootTest
@AutoConfigureMockMvc
class BentouControllerTest {
    @TestConfiguration
    public static class ConfigurationUme {
        @Bean("bentouController")
        public BentouController controller(@Qualifier("ume") OnigiriService service) {
            return new BentouController(service);
        };
    }

    @Autowired MockMvc mockMvc;

    @Test
    public void test() throws Exception {
        // 以下同様にアクセス

最後3つの方法で注意が必要なのは、TestConfiguration での上書き時の Bean 名を上書き対象のクラス名(のキャメルケース)と一致させないと Bean を上書きできず別の Bean として登録されてしまう点です。

  • 最後から2, 3番目の方法の場合別の Bean として登録されると例外が発生します。@Qualifier("tuna") な OnigiriService が2つ存在することになり、BentouController にどちらをインジェクトすべきか曖昧になるためです(なので登録自体ができません)。
  • 一番最後の方法の場合も別の Bean として登録されると例外が発生します(以下)。/bentou エンドポイントが既に既存 Bean でマッピングされているためです(なのでやっぱり登録自体ができません)。
Exception encountered during context initialization 
cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource 
// 略
java.lang.IllegalStateException: Ambiguous mapping. 
Cannot map 'controller' method com.example.bentou.BentouController#provide() to {GET [/bentou]}: 
There is already 'bentouController' bean method com.example.bentou.BentouController#provide() mapped.

「そこには 'bentouController' のメソッドが既にマッピングされている」という怒られに応じてメソッド名を bentouController に合わせれば解決します。