Tuesday, June 13, 2023

Spring @WebMvcTestにおけるListパラメータとNoSuchMethodExceptionの深層

Spring Frameworkは、堅牢で効率的なWebアプリケーションを構築するための強力なエコシステムを提供します。特に、Spring REST DocsはテストからAPIドキュメントを生成する画期的なアプローチを採用し、ドキュメントが常に最新かつ正確であることを保証します。しかし、その強力な機能の裏で、Springの内部的な動作に起因する予期せぬエラーに遭遇することがあります。その中でも特に開発者を悩ませるのが、@WebMvcTest環境でListをリクエストパラメータとして受け取ろうとした際に発生するjava.lang.NoSuchMethodException: java.util.List.<init>()です。

このエラーメッセージは一見すると不可解です。「なぜフレームワークがインターフェースであるListをインスタンス化しようとするのか?」、「なぜ本番環境では問題なく動作するコードが、テスト環境でのみ失敗するのか?」といった疑問が浮かびます。この記事では、この特定のエラーの根本原因を深く掘り下げ、そのメカニズムを解明し、単なる対症療法ではない、状況に応じた複数の実践的な解決策を提示します。

問題の再現:具体的なシナリオ

まず、問題が発生する典型的な状況をコードで確認しましょう。複数のIDやタグをクエリパラメータで受け取り、それに基づいてデータを検索するようなAPIエンドポイントを考えます。

コントローラーの実装

以下は、文字列のリストをリクエストパラメータとして受け取る、ごく一般的なコントローラーのメソッドです。


@RestController
@RequestMapping("/api/items")
public class ItemController {

    private final ItemService itemService;

    // Constructor injection
    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }

    @GetMapping
    public ResponseEntity<List<ItemDto>> findItemsByTags(@RequestParam List<String> tags) {
        // 受け取ったtagsリストを使ってビジネスロジックを実行
        List<ItemDto> items = itemService.findItemsByTags(tags);
        return ResponseEntity.ok(items);
    }
}

このコードは、/api/items?tags=java,spring,restのようなリクエストを受け取ることを意図しています。Spring MVCはカンマ区切りの文字列を自動的にList<String>に変換してくれるため、アプリケーションを直接実行した際には期待通りに動作します。

テストコードとエラーの発生

次に、このコントローラーの振る舞いを検証し、Spring REST Docsでドキュメントを生成するためのテストコードを作成します。ここでは、Webレイヤーに特化したテストスライスである@WebMvcTestを使用します。


@WebMvcTest(ItemController.class)
@AutoConfigureRestDocs
class ItemControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ItemService itemService;

    @Test
    void findItemsByTagsTest() throws Exception {
        // Given: itemServiceのモック設定
        when(itemService.findItemsByTags(anyList())).thenReturn(Collections.emptyList());

        // When & Then: APIを呼び出し、例外が発生する
        this.mockMvc.perform(get("/api/items")
                        .param("tags", "java", "spring", "rest"))
                .andExpect(status().isOk())
                .andDo(document("items-by-tags",
                        requestParameters(
                                parameterWithName("tags").description("検索対象のタグ(複数指定可)")
                        )
                ));
    }
}

.param("tags", "java", "spring", "rest")の部分は、/api/items?tags=java&tags=spring&tags=restという形式のクエリパラメータを生成します。これもまた、Spring MVCが正しくリストにバインドできる形式です。しかし、このテストを実行すると、成功するどころか、以下のような恐ろしいスタックトレースと共に失敗します。


org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.NoSuchMethodException: java.util.List.<init>()
...
Caused by: java.lang.NoSuchMethodException: java.util.List.<init>()
    at java.base/java.lang.Class.getConstructor0(Class.java:3585)
    at java.base/java.lang.Class.getConstructor(Class.java:2271)
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:211)
    ... more stack trace

スタックトレースの中心にあるのはCaused by: java.lang.NoSuchMethodException: java.util.List.<init>()です。これはJavaのコアな例外であり、指定されたシグネチャを持つコンストラクタが見つからないことを示しています。この場合、フレームワークが引数なしのデフォルトコンストラクタList()を呼び出してjava.util.Listのインスタンスを生成しようとして失敗していることを意味します。Listはインターフェースであり、具体的な実装(例: ArrayList, LinkedList)を持たないため、直接インスタンス化することはできません。これがエラーの直接的な原因です。

エラーの根本原因:なぜ`List`をインスタンス化しようとするのか?

問題は、「なぜSpring MVCのデータバインディング機構が、@WebMvcTestの文脈においてのみ、このような奇妙な振る舞いをするのか?」という点にあります。

この謎を解く鍵は、Springのデータバインディングと@WebMvcTestのスライス機能の相互作用にあります。

  1. データバインディングの基本プロセス: HTTPリクエストがディスパッチャサーブレットに到着すると、Spring MVCはリクエストパラメータをコントローラーメソッドの引数にマッピングしようとします。このプロセスをデータバインディングと呼びます。単純な型(String, intなど)の場合、変換は直接的です。しかし、コレクションのような複雑な型の場合、フレームワークはまずその型のインスタンスを生成し、その後でリクエストからの値(例: "java", "spring")をそのインスタンスに一つずつ追加していく、という手順を踏むことがあります。
  2. リフレクションとデフォルトコンストラクタ: フレームワークが「その型のインスタンスを生成」しようとするとき、多くの場合、JavaのリフレクションAPIを用いて対象クラスのデフォルトコンストラクタ(引数なしコンストラクタ)を呼び出します。POJO(Plain Old Java Object)を@ModelAttributeでバインドする場合を考えると、この仕組みは非常にうまく機能します。
  3. インターフェースの問題: しかし、バインド対象がListのようなインターフェースである場合、このアプローチは破綻します。どの具象クラス(ArrayListなのかLinkedListなのか)をインスタンス化すべきか、フレームワークは自明には判断できません。そのため、最も単純な試みとしてListインターフェースそのものをインスタンス化しようとし、前述のNoSuchMethodExceptionが発生するのです。
  4. @WebMvcTestの特異性: ではなぜ、完全なアプリケーションコンテキスト(@SpringBootTestや本番環境)では問題が起きないのでしょうか?それは、完全なコンテキストでは、より高度なConversionServiceが設定されており、文字列からコレクション型への変換をより賢く処理するコンバータが多数登録されているからです。これらのコンバータは、Listインターフェースに対して適切なデフォルト実装(通常はArrayList)を自動的に選択してインスタンスを生成し、値を設定してくれます。 一方で、@WebMvcTestはテストの速度と分離を目的とした「スライステスト」です。Webレイヤーに関連する最小限のコンポーネント(コントローラー、フィルター、コンバータなど)のみをロードします。この最小限の構成では、完全なアプリケーションコンテキストで利用可能な高度なコレクション変換の仕組みの一部がデフォルトで有効になっていない場合があるのです。その結果、データバインディング機構がより原始的なリフレクションベースのインスタンス生成ロジックにフォールバックし、問題が露呈します。

要するに、このエラーはSpringのバグではなく、テストスライスの軽量な構成と、コレクション型に対するデータバインディングの仕組みとの間の相互作用によって引き起こされる現象なのです。

解決策1:配列(Array)への変更

この問題に対する最も直接的で簡単な解決策は、コントローラーメソッドの引数の型をList<String>からString[](文字列の配列)に変更することです。

修正後のコントローラー


@RestController
@RequestMapping("/api/items")
public class ItemController {

    // ... (Constructor and other parts remain the same)

    @GetMapping
    public ResponseEntity<List<ItemDto>> findItemsByTags(@RequestParam String[] tags) {
        // 配列をリストに変換してサービスレイヤーに渡す
        List<String> tagList = Arrays.asList(tags);
        List<ItemDto> items = itemService.findItemsByTags(tagList);
        return ResponseEntity.ok(items);
    }
}

テストコード側は、多くの場合、修正する必要はありません。.param("tags", "java", "spring", "rest")は配列へのバインディングにも有効です。

なぜ配列だと機能するのか?

この変更が機能する理由は、Javaにおいて配列はインターフェースではなく、言語レベルで定義された具体的な型だからです。Springのデータバインディング機構は、String[]という型を見れば、new String[size]という明確な方法でインスタンスを生成できることを知っています。リフレクションで不確定な具象クラスを探し回る必要がないため、@WebMvcTestの最小限の構成でも問題なく動作します。

ビジネスロジック層(Serviceなど)では引き続きListを扱う方が便利な場合が多いため、コントローラー内でArrays.asList(tags)のように一度変換を挟むのが一般的です。この方法は手軽ですが、APIのシグネチャが変更される点、またコントローラーに小さな変換ロジックが加わる点がデメリットとして挙げられます。

解決策2:ラッパーオブジェクト(DTO)の使用

よりクリーンで拡張性の高いアプローチは、リクエストパラメータをカプセル化する専用のDTO(Data Transfer Object)を作成することです。

DTOの作成

まず、リクエストパラメータを受け取るためのクラスを定義します。


public class TagSearchRequest {
    private List<String> tags;

    // Getter and Setter for 'tags'
    public List<String> getTags() {
        return tags;
    }

    public void setTags(List<String> tags) {
        this.tags = tags;
    }
}

このクラスには、デフォルトコンストラクタ(明示的に書かなくてもコンパイラが生成します)と、リクエストパラメータ名に一致するプロパティ(およびそのセッター)が含まれています。このセッターの存在が重要です。

修正後のコントローラー

次に、コントローラーを修正して、このDTOを引数として受け取るようにします。@RequestParamの代わりに@ModelAttribute(または省略可能)を使用します。


@RestController
@RequestMapping("/api/items")
public class ItemController {

    // ... (Constructor)

    @GetMapping
    public ResponseEntity<List<ItemDto>> findItemsByTags(TagSearchRequest request) {
        // DTOからリストを取得してビジネスロジックを実行
        List<String> tagList = request.getTags();
        List<ItemDto> items = itemService.findItemsByTags(tagList);
        return ResponseEntity.ok(items);
    }
}

なぜDTOだと機能するのか?

このアプローチでは、Springのデータバインディングは異なるロジックで動作します。

  1. まず、TagSearchRequestクラスのインスタンスを生成します。このクラスは具象クラスであり、デフォルトコンストラクタを持つため、BeanUtils.instantiateClassは問題なく成功します。
  2. 次に、リクエストパラメータのtagsの値を、インスタンスのsetTags(List<String> tags)メソッドを呼び出して設定しようとします。
  3. このセッターメソッドの引数であるList<String>を解決する段階で、@WebMvcTest環境でも適切に設定されたコンバータが動作し、文字列のシーケンスからArrayListが生成され、セッターに渡されます。

つまり、直接メソッド引数としてListをバインドしようとする際の曖昧さが、DTOという具象クラスを介することで解消されるのです。この方法は、将来的に検索条件(例: ソート順、ページサイズなど)が増えた場合にも、DTOにフィールドを追加するだけで対応できるため、非常に高い拡張性を持ちます。

Spring REST Docsでのドキュメント化

どちらの解決策を採用したかによって、ドキュメントの記述方法も変わります。

配列(Array)の場合

配列の場合、ドキュメント化は非常にシンプルです。テストコードのrequestParametersスニペットは元のまま、あるいは少し説明を調整するだけで十分です。


.andDo(document("items-by-tags-array",
    requestParameters(
        parameterWithName("tags").description("検索対象のタグ。同じパラメータ名で複数回指定します。(例: ?tags=java&tags=spring)")
    )
));

DTOの場合

DTOを使用した場合も同様にrequestParametersスニペットを使用します。ただし、ドキュメント化するパラメータ名はDTOのフィールド名(この場合はtags)に対応します。


.andDo(document("items-by-tags-dto",
    requestParameters(
        parameterWithName("tags").description("検索対象のタグ。同じパラメータ名で複数回指定します。(例: ?tags=java&tags=spring)")
    )
));

どちらの方法でも、REST Docsは正しくパラメータを認識し、ドキュメントを生成できます。重要なのは、テストが成功するようになったことで、ドキュメント生成プロセスが最後まで完了できるという点です。

まとめと推奨事項

@WebMvcTest環境で発生するjava.lang.NoSuchMethodException: java.util.List.<init>()は、Springのテストスライス機能の特性とデータバインディングの内部メカニズムが交差する点で生じる、示唆に富んだエラーです。

この問題から得られる教訓は以下の通りです。

  • 原因: エラーの根本は、@WebMvcTestの最小限のコンテキストでは、インターフェースであるListを直接メソッド引数にバインドするための高度なコンバータが不足していることにあります。その結果、フレームワークはリフレクションによるListインターフェースの直接的なインスタンス化を試み、失敗します。
  • 解決策1(配列): 最も手軽な解決策は、引数の型をList<String>からString[]に変更することです。配列は具象型であるため、インスタンス化で問題は起きません。
  • 解決策2(DTO): より堅牢で推奨される解決策は、リクエストパラメータをカプセル化するDTOを作成することです。これにより、データバインディングのプロセスがより明確になり、コードの可読性や拡張性が向上します。

プロジェクトの状況に応じてどちらの解決策を選択するかは異なりますが、一般的には、リクエストパラメータが2つ以上になる可能性がある場合や、よりクリーンな設計を維持したい場合には、DTOを使用するアプローチを強く推奨します。これにより、今回のようなフレームワークの内部動作に依存した問題から解放され、より安定したテストと保守性の高いコードベースを維持することができます。

この一見すると難解なエラーも、その背後にあるSpring Frameworkの仕組みを理解することで、明確な解決策を見出すことができます。同様の問題に直面した開発者が、この記事を通じて迅速に問題を解決し、より本質的なアプリケーション開発に集中できるようになれば幸いです。

```

0 개의 댓글:

Post a Comment