😺

実務で詰まった話

に公開

明けましておめでとうございます!

オアシステクノロジーズの中村です。

2025年一発目、半年振りの投稿です。

同じPJに参画しつつ、本番リリースが終わり今は保守フェーズに入ってます。

その中で詰まった箇所があったので今回はそれについて書いてみます。

今回のトピック

  1. 現システムの復習
  2. やりたいこと
  3. コードの修正
  4. UTで詰まった箇所

現システムの復習

本題に入る前に今のシステム構造を復習します。

こんな感じでBFFを採用していて、自分はBFFレイヤーを担当しています。

フロントエンドからBFFのAPIをコールされるとBFF内で単一または複数のバックエンドAPIをコールし、フロントエンドが欲しいレスポンスに加工して返却します。

alt text

今回はヘルスチェックのUTで詰まったのですが、BFFのUTでは通常以下のようにしています。

(ChatGPTで出力しています。すごすぎて驚き)


📄 ファイル構成

  • UserController.java: BFFレイヤーでのユーザー情報取得エンドポイント
  • UserService.java: OpenAPIクライアントを用いたバックエンドAPI呼び出し処理(メソッド呼び出し対応)
  • UserControllerTest.java: ユニットテストコード(JUnit 5 + Mockito + MockMvc使用)

📝 1️⃣ UserController.java


@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getUserById(@PathVariable Long id) {
        return userService.fetchUserById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.status(404).body("User not found"));
    }
}

📝 2️⃣ UserService.java (OpenAPIクライアント使用 & メソッド呼び出し対応)


@Service
public class UserService {

    private final UserApi userApi;

    public UserService(UserApi userApi) {
        this.userApi = userApi;
    }

    public Optional<User> fetchUserById(Long id) {
        try {
            return Optional.ofNullable(userApi.getUserById(id));
        } catch (Exception e) {
            System.err.println("[Error] API呼び出し失敗: " + e.getMessage());
            return Optional.empty();
        }
    }
}

📝 3️⃣ UserControllerTest.java (ユニットテスト: MockMvc + Mockito使用)


@WebMvcTest(UserController.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserApi userApi;

    @BeforeEach
    public void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testGetUserById_Success() throws Exception {
        User mockUser = new User().id(1L).name("田中太郎");
        when(userApi.getUserById(1L)).thenReturn(Optional.of(mockUser));

        mockMvc.perform(get("/users/1")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.name").value("田中太郎"));

        verify(userApi, times(1)).getUserById(1L);
    }

    @Test
    public void testGetUserById_NotFound() throws Exception {
        when(userApi.getUserById(2L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/users/2")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound())
                .andExpect(content().string("User not found"));

        verify(userApi, times(1)).getUserById(2L);
    }

    @Test
    public void testGetUserById_InternalServerError() throws Exception {
        when(userApi.getUserById(3L)).thenThrow(new RuntimeException("Unexpected Error"));

        mockMvc.perform(get("/users/3")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isInternalServerError());

        verify(userApi, times(1)).getUserById(3L);
    }
}

一瞬でここまで出せました。。。

こんな感じでUserControllerのgetUserByIdがコールされたらバックエンドのgetUserByIdをコールしてレスポンスをマッピングしてreturnしています。

コントローラーのUTではバックエンドのAPIクラスであるUserApiをモック化してgetUserByIdメソッドに特定の値が渡されたらthenReturnを返却する仕組みを記述しコントローラーのメソッドを走らせて想定通りのレスポンスをするか確認しています。

やりたいこと

さてBFFの記述とUTはおさらいできたので次は今回やりたい実装です。

以下の図にある通りBFFではRedisやバックエンドコンテナ、DBが立ち上がっているか確認できるようにAWSのロードバランサーからヘルスチェックをコールされています。

alt text

以下のソースがそれっぽい感じでなんですが、バックエンドって外部システムと連携してることが多いと思います。

その外部システムにもヘルスチェックをしてるんですが、電源落ちたとかでヘルスチェックがエラーを吐いたときにどのシステムとの通信が切れているかを確認したいというのがやりたいことです。


📄 ファイル構成

  • HealthCheckController.java: BFFレイヤーでのヘルスチェックエンドポイント
  • HealthCheckService.java: バックエンドヘルスチェック処理(メソッド呼び出し対応)

📝 1️⃣ HealthCheckController.java


@RestController
@RequestMapping("/health-check")
public class HealthCheckController {

    private final HealthcheckService healthcheckService;

    public HealthCheckController(HealthcheckService healthcheckService) {
        this.healthcheckService = healthcheckService;
    }

    @GetMapping("/healthcheck")
    public String serviceHealthcheck() {
        healthcheckService.checkA();
        healthcheckService.checkB();
        return "SUCCESS";
    }
}

📝 2️⃣ HealthCheckService.java (バックエンドヘルスチェック処理(メソッド呼び出し対応)


@Service
public class HealthCheckService {

    @Autowired
    RestTemplate restTemplate;

    @Autowired
    StringRedisTemplate redisTemplate;

    public void checkA() {
        RedisConnection connection;

        try {
            connection = redisTemplate.getConnectionFactory().getConnection();
            connection.ping();
        } catch (Exception e) {
            throw new SystemException(null, "エラーメッセージ");
        }
        connection.close();
    }

    public void checkB() {
        String url = "healthcheckのurl";

        try {
            restTemplate.exchange(url HttpMethod.GET, null, String.class);
        } catch (Exception e) {
            throw new SystemException(null, "エラーメッセージ");
        }
    }
}

コードの修正

ここはそこまで難しくないのでサクッといきます。


📝 1️⃣ HealthCheckController.java


@RestController
@RequestMapping("/health-check")
public class HealthCheckController {

    private final HealthcheckService healthcheckService;

    public HealthCheckController(HealthcheckService healthcheckService) {
        this.healthcheckService = healthcheckService;
    }

    @GetMapping("/healthcheck")
    public String serviceHealthcheck() {
        healthcheckService.checkA();
        healthcheckService.checkB();
        return "SUCCESS";
    }
}

📝 2️⃣ HealthCheckService.java (バックエンドヘルスチェック処理(メソッド呼び出し対応)


@Service
public class HealthCheckService {

    @Autowired
    RestTemplate restTemplate;

    @Autowired
    StringRedisTemplate redisTemplate;

    public void checkA() {
        RedisConnection connection;

        try {
            connection = redisTemplate.getConnectionFactory().getConnection();
            connection.ping();
        } catch (Exception e) {
            throw new SystemException(null, "エラーメッセージ");
        }
        connection.close();
    }

    public void checkB() {
        try {
            restTemplate.exchange("/url", HttpMethod.GET, null, String.class);
        } catch (RestClientResponseException e) {
            // 5xx系のサーバーエラーの場合
            if (Objects.equals(e.getStatusCode(), HttpStatus.INTERNAL_SERVER_ERROR)) {
                // エクセプションからレスポンスのエラー情報を取得
                ExceptionEntity exceptionEntity = mapper.readValue(e.getResponseBodyAsString(), ExceptionEntity.class);
                String errorCode = exceptionEntity.getErrorCode();
                switch (errorCode) {
                    case "C" -> {
                        throw new SystemException(null, "Cのヘルスチェックがエラー");
                    }
                    case "D" -> {
                        throw new SystemException(null, "Dのヘルスチェックがエラー");
                    }
                }
            }
            throw new SystemException(null, "エラーメッセージ");
        }
    }
}

ざっくりこんな感じですね。

ヘルスチェック時に吐き出すエクセプション情報を事前に決めておいてcatch内で振り分けるだけです。

  1. UTで詰まった箇所

詰まったのはこっからでどやってUT記載しよ・・・?

これまではバックエンドのメソッドが分かっていたのでそれをモック化すればよかったですが、例えば以下の場合は何をモック化するんだろう・・ってなった訳です。


RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
connection.ping();


結果として以下のようなUTとなりました。

@Autowired
MockMvc mockMvc;

@SpyBean
RestTemplate restTemplate;

@SpyBean
StringRedisTemplate redisTemplate;

@Mock
private RedisConnectionFactiory connectionFactiory;

@Mock
private RedisConnection redisConnection;

void UT1() throws Exception {
    doReturn(connectionFactiory).when(redisTemplate).getConnectionFactory();
    doReturn(redisConnection).when(connectionFactiory).getConnection;

    doReturn(new ResponseEntity<>("response", HttpStatus.OK)).when(restTemplate)
    .exchange("/url", HttpMethod.GET, null, String.class);

    mockMvc.pereform(get("/healthcheck")).andExpect(Status.isOk());
}

AIに聞きまくったんですが、、、なるほど~。

インスタンスをモック化は通常通りだけど、メソッドの戻り値もモック化してさらにその先のメソッドまでモック化する方法があるんですね。

ちなみに@SpyBeanも初めて出たので改めてAIに聞いてみました。


📚 *@SpyBeanの解説

✅ @SpyBeanとは?

💡 定義

実際のインスタンス(本物のクラス)を作成するけど、一部のメソッドだけをモック化するための仕組みです。

特徴

@MockBean @SpyBean
インスタンス 完全なモック(中身は空) 実際のクラスを生成
実行内容 すべてスタブ化 必要なメソッドだけモック可能

ポイント

  • サービス層でデータ加工やログ出力などは本物を使いたい
  • でも特定API呼び出しはモックで挙動を制御したい

このようなケースで@SpyBeanは非常に便利です。


今回はping()メソッドは動かす必要あったからSpyBeanにしてるのかな?

色々エラー出すぎて過程を覚えておらず・・・

こちらは異常系の確認です

void UT2() throws Exception {
    doThrow(new RedisConnectionFailureException("Connectionfailed")).when(redisTemplate).getConnectionFactory();

    MvcResult result = mockMvc.pereform(get("/healthcheck")).andExpect(Status().is5xxServerError()).andReturn();
}

これでエラーを出せました。

redisTemplate.getConnectionFactory()が動いたらエラーを吐くような記述になっています。

DB側のエラーチェックは以下の通りです。

void UT3() throws Exception {
    RestClientException ex = new RestClientException("エラーコード", "エラーメッセージ", HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR");

    doThrow(ex).when(restTemplate).exchange("/url", HttpMethod.GET, null, String.class);

    MvcResult result = mockMvc.pereform(get("/healthcheck")).andExpect(Status().is5xxServerError()).andReturn();  
}

エラーコードの部分をswitchしたい任意のエラーコードに変えればcatchの中でif分を通りどのヘルスチェックでエラーが出ているかを判別できているかテストができます。

UTだと本当はエラーメッセージが正しいかのチェックとかしますが、本質ではないので飛ばします。

こんな感じでバックエンド側で実装しているメソッドをどうやってUTで確認するんだろうって感じで始まり、AIに聞いてなんとかできました。

躓いたときはどんなエラーが出たかをメモしたら後々復習できて良さそうですね!

多分しないですが・・・・(恐らくそれどころではないです笑)

以上!!!

Discussion