Showing posts with label RestDocs. Show all posts
Showing posts with label RestDocs. Show all posts

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の仕組みを理解することで、明確な解決策を見出すことができます。同様の問題に直面した開発者が、この記事を通じて迅速に問題を解決し、より本質的なアプリケーション開発に集中できるようになれば幸いです。

```

Navigating Parameter Binding Exceptions in Spring REST Docs

Spring REST Docs has firmly established itself as an indispensable tool for producing accurate, reliable, and maintainable API documentation. By leveraging tests to generate documentation snippets, it ensures that what you document is exactly what your API delivers. This test-driven approach is a significant leap forward from manual documentation methods, which are often prone to falling out of sync with the actual codebase. However, the path to perfect documentation is not always straightforward. Developers can encounter cryptic errors that stem from the intricate interactions between the Spring MVC framework, the testing environment, and the mechanics of documentation generation. One such perplexing issue is the java.lang.NoSuchMethodException when a controller attempts to bind request parameters to a java.util.List.

This article provides a comprehensive exploration of this specific error. We will dissect a real-world scenario where it occurs, delve into the underlying technical reasons related to Java's reflection and type system, present a clear and effective solution, and discuss alternative strategies and best practices. The goal is not just to fix the error, but to understand its origins, empowering you to write more robust and testable Spring applications.

The Anatomy of the `NoSuchMethodException`

To fully grasp the problem, let's construct a typical scenario where this error manifests. Imagine you are developing a RESTful API endpoint that needs to accept a variable number of identifiers to filter a resource. A common and intuitive way to model this is by accepting a list of strings or numbers as a request parameter.

A Common Controller Implementation

Consider a simple ProductController with a `GET` endpoint designed to fetch products based on a list of supplied IDs. The controller method signature might look like this:


@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<List<Product>> findProductsByIds(@RequestParam List<String> ids) {
        List<Product> products = productService.findByIds(ids);
        return ResponseEntity.ok(products);
    }
    
    // ... other methods
}

This implementation is clean, idiomatic Spring MVC. The framework is expected to automatically bind multiple query parameters with the same name (e.g., /products?ids=prod-101&ids=prod-204) into the ids list. When running the application and hitting this endpoint with a tool like cURL or Postman, it works flawlessly. The problem, however, appears when we try to write a test for it using Spring REST Docs.

The Failing Test Case

Following the principles of documentation-driven testing, we set up a @WebMvcTest to verify the controller's behavior and generate documentation snippets. The test might look something like this:


@WebMvcTest(ProductController.class)
@AutoConfigureRestDocs
public class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnProductsForGivenIds() throws Exception {
        List<Product> mockProducts = Arrays.asList(
            new Product("prod-101", "Laptop"),
            new Product("prod-204", "Mouse")
        );
        
        given(productService.findByIds(Arrays.asList("prod-101", "prod-204")))
            .willReturn(mockProducts);

        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/products?ids={ids}", "prod-101,prod-204")
            // Or alternatively: .param("ids", "prod-101").param("ids", "prod-204")
        )
        .andExpect(status().isOk())
        .andDo(document("products/find-by-ids",
            requestParameters(
                parameterWithName("ids").description("A list of product IDs to retrieve.")
            )
        ));
    }
}

This seems like a standard, correct test setup. We mock the service layer, define the expected behavior, perform a request using MockMvc, and set up REST Docs to document the request parameters. However, upon running this test, instead of a green checkmark, we are met with a failure and a rather unsettling stack trace.

The Stack Trace Unveiled

The test fails not with an assertion error, but with an exception deep within the framework during the test execution. The console output prominently features the following:


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:3349)
    at java.base/java.lang.Class.getConstructor(Class.java:2151)
    ... and so on

The core of the issue is java.lang.NoSuchMethodException: java.util.List.<init>(). This exception in Java typically means that code, usually via reflection, tried to find and invoke a no-argument constructor (<init>()) for the java.util.List class, but failed to do so. This is perfectly logical, because java.util.List is an interface. Interfaces cannot be instantiated; they have no constructors. One can only instantiate a concrete implementation like ArrayList or LinkedList. The immediate question is: why is the Spring test framework trying to instantiate a List interface directly?

Unraveling the Root Cause: Reflection, Type Erasure, and the Test Slice

The original diagnosis that "webMvcTest cannot correctly identify the List" is a symptom, not the root cause. The problem lies at the intersection of three key concepts: how Spring MVC binds parameters, the limitations of the @WebMvcTest slice, and Java's type erasure mechanism for generics.

How Spring Binds Request Parameters

In a running Spring Boot application, incoming HTTP requests are handled by a sophisticated chain of components. When a request for /products?ids=prod-101&ids=prod-204 arrives, the DispatcherServlet routes it to the correct controller method. Then, a component called a HandlerMethodArgumentResolver is responsible for resolving the method's arguments. For @RequestParam, the RequestParamMethodArgumentResolver is used. This resolver is intelligent; it recognizes that the target parameter is a collection (List) and correctly gathers all request parameters named "ids" into a new ArrayList instance, which is then passed to the controller method. This process works seamlessly in the full application context.

The `@WebMvcTest` Slice Limitation

The key difference is the test environment. The @WebMvcTest annotation does not load the entire application context. Instead, it sets up a "test slice" containing only the beans necessary for testing the web layer: controllers, JSON converters, Filters, WebMvcConfigurers, and crucially, the HandlerMethodArgumentResolvers. However, the auto-configuration for this test slice can sometimes differ subtly from the full application's configuration. In this specific scenario, the machinery responsible for parameter binding and documentation generation within the MockMvc environment appears to interact with the controller method's signature in a way that leads to the error.

The Collision of Reflection and Generics

The final piece of the puzzle is Java's type system. Java uses a mechanism called type erasure for generics. This means that at compile time, `List<String>` is checked for type safety, but at runtime, the type information is erased, and the JVM only sees a raw `java.util.List`.

Here's a plausible sequence of events inside the test framework:

  1. The framework introspects the findProductsByIds(@RequestParam List<String> ids) method signature to understand its parameters for both request processing and documentation.
  2. Through reflection, it determines the parameter type is java.util.List.class (due to type erasure).
  3. At some point in the setup or binding process, a component attempts to create an instance of this parameter type to populate it. It does so by reflectively looking for a default, no-argument constructor: List.class.getConstructor().newInstance().
  4. This operation fails catastrophically because List.class represents an interface, which has no constructors. The result is the NoSuchMethodException we observed.

Why does this not happen in the running application? The production-ready RequestParamMethodArgumentResolver is coded to specifically handle collection types and knows to instantiate a concrete class like ArrayList instead of blindly trying to instantiate the interface type it sees. The testing or documentation-generation context seems to be using a more generic, reflection-based mechanism that stumbles on this detail. In contrast, an array type like String[] does not suffer from this ambiguity. At runtime, String[].class is a concrete, non-erased type that the reflection APIs can handle without confusion.

The Pragmatic Solution and Its Nuances

Understanding the root cause allows us to implement a targeted and effective solution. Since the problem is rooted in the ambiguity of the List interface during reflection in the test environment, the solution is to use a concrete type that avoids this ambiguity: an array.

Implementing the Fix: From List to Array

The fix involves a simple change to the controller's method signature. By replacing List<String> with String[], we provide the test framework with a concrete type that it can handle correctly.

Before:


@GetMapping
public ResponseEntity<List<Product>> findProductsByIds(@RequestParam List<String> ids) {
    // ...
}

After:


@GetMapping
public ResponseEntity<List<Product>> findProductsByIds(@RequestParam String[] ids) {
    // Inside the method, you can easily convert the array to a List if needed
    List<String> idList = Arrays.asList(ids);
    List<Product> products = productService.findByIds(idList);
    return ResponseEntity.ok(products);
}

Spring MVC's parameter binding handles arrays just as gracefully as it handles lists. It will collect all query parameters named "ids" and populate the String[] ids array.

The Corrected Test Case

With the controller method signature updated, the existing test case will now pass without any modifications. The MockMvc and REST Docs frameworks can now correctly interpret the String[] parameter type, the NoSuchMethodException is avoided, and the test proceeds to validate the endpoint's logic and generate the documentation.


// ... same test setup as before ...
@Test
void shouldReturnProductsForGivenIds() throws Exception {
    // The service mock might need a slight adjustment if its method signature expects a List
    List<String> idList = Arrays.asList("prod-101", "prod-204");
    List<Product> mockProducts = Arrays.asList(
        new Product("prod-101", "Laptop"),
        new Product("prod-204", "Mouse")
    );
    
    given(productService.findByIds(idList)).willReturn(mockProducts);

    // This test now passes
    this.mockMvc.perform(RestDocumentationRequestBuilders.get("/products?ids={ids}", "prod-101,prod-204"))
        .andExpect(status().isOk())
        .andDo(document("products/find-by-ids",
            requestParameters(
                parameterWithName("ids").description("A list of product IDs to retrieve.")
            )
        ));
}

Is This an Ideal Solution?

From a purely pragmatic standpoint, this is an excellent solution. It's a minimal change that resolves a frustrating testing issue. However, from a design perspective, one could argue that using List in method signatures is more idiomatic and aligned with the principles of programming to an interface. In this case, it's important to recognize that this change is a concession to the realities of the testing framework. The public-facing contract of your API remains unchanged. A client calling /products?ids=a&ids=b does not know or care whether the server-side implementation uses a List<String> or a String[]. The change is an internal implementation detail made to facilitate testing.

Exploring Alternative Approaches

While the array-based solution is the most direct fix, it's worth being aware of other patterns that can also circumvent this issue, especially in more complex scenarios.

Using a Wrapper Object (DTO)

You can encapsulate the request parameters into a Data Transfer Object (DTO). This approach works well when you have multiple related filter parameters. The controller method then uses @ModelAttribute instead of multiple @RequestParam annotations.


// DTO Class
public class ProductQuery {
    private List<String> ids;
    // getters and setters
}

// Updated Controller
@GetMapping
public ResponseEntity<List<Product>> findProducts(@ModelAttribute ProductQuery query) {
    List<Product> products = productService.findByIds(query.getIds());
    return ResponseEntity.ok(products);
}

In this case, Spring's data binding mechanism will instantiate the ProductQuery object and then populate its fields. This process avoids the direct reflective instantiation of the `List` interface that causes the original problem.

Using Comma-Separated Values

Another common pattern for passing multiple values is to use a single, comma-separated string.


// Controller accepts a single String
@GetMapping
public ResponseEntity<List<Product>> findProductsByIds(@RequestParam String ids) {
    List<String> idList = Arrays.asList(ids.split(","));
    List<Product> products = productService.findByIds(idList);
    return ResponseEntity.ok(products);
}

The client would then call the endpoint like /products?ids=prod-101,prod-204. This is a very simple and robust approach that completely avoids collection binding issues. The main drawback is that it doesn't gracefully handle values that might themselves contain commas.

Impact on Documentation and Best Practices

A crucial question is how this change from List to an array affects the final output from Spring REST Docs. Fortunately, the impact is minimal to none.

Verifying the Generated Snippet

Spring REST Docs is primarily concerned with the HTTP request and response, not the specific Java types used in the controller. Whether the backing type is a List<String> or a String[], a request made with multiple `ids` parameters (e.g., ?ids=a&ids=b) is identical on the wire. As a result, the generated documentation snippet for request parameters will be the same in both cases. For example, the `request-parameters.adoc` file will contain something like this:


|===
| Parameter | Description

| `ids`
| A list of product IDs to retrieve.

|===

This demonstrates that our testing-motivated change did not compromise the accuracy or clarity of the consumer-facing documentation.

Key Takeaways for Robust API Testing

This specific issue serves as a valuable lesson in building and testing Spring applications:

  • Understand Your Test Slices: Be aware that @WebMvcTest, @DataJpaTest, etc., provide focused, partial application contexts. Their behavior can sometimes diverge from the full context.
  • Be Mindful of Reflection and Generics: Frameworks like Spring rely heavily on reflection. Be aware of how Java features like type erasure can lead to unexpected behavior, especially in a testing environment.
  • Prioritize Testable Signatures: When designing controllers, consider how easily they can be tested. Sometimes, a small change in a method signature, like using an array instead of a list, can save hours of debugging test failures.
  • Focus on the API Contract: Remember that internal implementation choices made for testability should not negatively impact the public API contract that your consumers depend on.

Conclusion

The java.lang.NoSuchMethodException: java.util.List.<init>() error in a Spring REST Docs test is a classic example of a problem that is simple to fix but complex in its origin. It's a confluence of test context limitations, framework reflection, and the nuances of Java's type system. By replacing the problematic List parameter with a concrete String[] array, we provide a clear and unambiguous type for the testing framework to handle, resolving the error without altering the external behavior of our API. More importantly, by digging deep into the "why," we gain a richer understanding of the tools we use every day, making us better equipped to tackle the next unexpected challenge that comes our way.

Tuesday, December 11, 2018

Spring REST Docs List 파라미터 테스트 시 NoSuchMethodException 완전 정복

Spring Boot와 Spring REST Docs를 사용하여 RESTful API를 개발하고 테스트하는 것은 현대적인 백엔드 개발의 표준적인 워크플로우 중 하나입니다. 특히 Spring REST Docs는 테스트 코드를 기반으로 항상 최신 상태를 유지하는 정확한 API 문서를 생성해주기 때문에, Swagger와 같은 도구와는 다른 결의 강력한 장점을 제공합니다. 개발자는 테스트 케이스를 작성함으로써 자연스럽게 문서까지 완성하게 되어, 문서와 실제 코드 간의 불일치를 원천적으로 방지할 수 있습니다. 그러나 이 강력한 도구를 사용하는 과정에서 때때로 개발자를 당황하게 만드는 예외 상황을 마주치기도 합니다. 그 중에서도 특히 @WebMvcTest 환경에서 List 형태의 파라미터를 처리할 때 발생하는 java.lang.NoSuchMethodException: java.util.List.<init>() 오류는 많은 개발자들이 한 번쯤 겪어봤을 법한 까다로운 문제입니다. 이 글에서는 해당 오류가 왜 발생하는지 근본적인 원인을 심층적으로 분석하고, 다양한 관점에서 제시할 수 있는 해결책들을 상세한 코드 예제와 함께 제시하여 이 문제를 완벽하게 정복할 수 있도록 돕겠습니다.

문제 상황 재현: 에러는 언제, 어떻게 나타나는가?

이론적인 설명에 앞서, 어떤 상황에서 NoSuchMethodException이 발생하는지 구체적인 코드를 통해 재현해 보겠습니다. 문제 상황을 명확히 이해하는 것이 해결의 첫걸음입니다.

1. 문제의 Controller 작성

먼저, 여러 개의 검색 키워드를 Query Parameter를 통해 List 형태로 받는 간단한 Controller를 작성해 봅시다. 예를 들어, /api/search?keywords=spring&keywords=jpa 와 같은 요청을 처리하는 엔드포인트입니다.


import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.stream.Collectors;

@RestController
public class SearchController {

    @GetMapping("/api/search")
    public ResponseEntity<String> searchByKeywords(List<String> keywords) {
        if (keywords == null || keywords.isEmpty()) {
            return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
        }
        String result = "검색된 키워드: " + keywords.stream().collect(Collectors.joining(", "));
        return ResponseEntity.ok(result);
    }
}

위 코드는 특별할 것 없는 평범한 컨트롤러입니다. Spring MVC는 동일한 이름으로 여러 개의 Query Parameter가 들어오면 이를 자동으로 List 또는 배열로 매핑해주는 편리한 기능을 제공합니다. 따라서 위 코드 자체에는 아무런 문제가 없습니다. 실제로 애플리케이션을 실행하고 curl "localhost:8080/api/search?keywords=java&keywords=test"와 같이 요청을 보내면 정상적으로 "검색된 키워드: java, test" 라는 응답을 받을 수 있습니다.

2. 문제를 유발하는 @WebMvcTest 작성

문제는 Spring REST Docs와 함께 @WebMvcTest를 사용하여 이 컨트롤러를 테스트할 때 발생합니다. @WebMvcTest는 웹 레이어에 대한 슬라이스 테스트(Slice Test)를 지원하며, 전체 애플리케이션 컨텍스트를 로드하지 않고 MVC 관련 빈들만 로드하여 빠르고 가볍게 테스트를 진행할 수 있게 해줍니다.

아래와 같이 REST Docs 문서 생성을 포함한 테스트 코드를 작성해 보겠습니다.


import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


@WebMvcTest(SearchController.class)
@AutoConfigureRestDocs // REST Docs 자동 설정 활성화
@ExtendWith(RestDocumentationExtension.class) // JUnit 5에서 REST Docs 사용을 위한 확장
public class SearchControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 키워드_리스트로_검색_테스트() throws Exception {
        mockMvc.perform(get("/api/search")
                        .param("keywords", "spring", "jpa", "restdocs"))
                .andExpect(status().isOk())
                .andDo(document("search-by-keywords",
                        queryParameters(
                                parameterWithName("keywords").description("검색할 키워드 목록 (다중값 가능)")
                        )
                ));
    }
}

이 테스트 코드를 실행하면, 우리는 성공을 기대하지만 안타깝게도 다음과 같은 끔찍한 예외를 마주하게 됩니다.


org.springframework.web.util.NestedServletException: Request processing failed; nested exception is java.lang.NoSuchMethodException: java.util.List.<init>()

    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
    ...
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.getResolvableConstructor(BeanUtils.java:274)
    ... 40 more

로그의 핵심은 Caused by: java.lang.NoSuchMethodException: java.util.List.<init>() 입니다. 직역하면 'java.util.List의 생성자(<init>)를 찾을 수 없다'는 의미입니다. 이 에러 메시지는 왜 발생하는 것일까요? 당연히 List는 인터페이스이므로 생성자가 없는 것이 당연한데, 왜 Spring은 List를 직접 생성하려고 시도하는 것일까요?

에러의 근본 원인: MockMvc 환경과 데이터 바인딩의 미스터리

오류의 원인을 이해하기 위해서는 Spring MVC의 데이터 바인딩(Data Binding) 메커니즘과, 전체 애플리케이션 환경과 @WebMvcTest 슬라이스 테스트 환경의 미묘한 차이를 알아야 합니다.

1. Spring MVC의 데이터 바인딩 과정

클라이언트로부터 HTTP 요청이 들어오면, DispatcherServlet은 HandlerMapping을 통해 요청을 처리할 컨트롤러와 메서드를 찾습니다. 그 후, HandlerAdapter는 해당 메서드를 호출해야 하는데, 이때 요청에 포함된 파라미터들(Query Parameter, Path Variable, Request Body 등)을 메서드의 인자(Argument) 타입에 맞게 변환하고 주입해주는 과정이 필요합니다. 이 과정을 '데이터 바인딩'이라 하고, HandlerMethodArgumentResolver 인터페이스의 구현체들이 이 역할을 담당합니다.

우리가 겪는 문제의 상황, 즉 Query Parameter를 컨트롤러 메서드의 인자로 바인딩하는 것은 RequestParamMethodArgumentResolver와 관련이 깊습니다. 이 리졸버는 다음과 같은 순서로 동작합니다.

  1. HTTP 요청에서 파라미터 이름('keywords')에 해당하는 값들을 추출합니다. 여러 개일 경우 문자열 배열(String[]) 형태로 가져옵니다. (e.g., ["spring", "jpa", "restdocs"])
  2. 이 문자열 배열을 컨트롤러 메서드의 목표 타입(List<String>)으로 변환해야 합니다.
  3. 이 변환 과정에서 Spring은 내부적으로 ConversionService를 사용합니다. ConversionService는 다양한 타입 변환기(Converter)를 등록하고 관리하는 역할을 합니다. 일반적으로 String[] -> List<String> 변환을 처리할 수 있는 컨버터가 등록되어 있습니다.

2. @WebMvcTest 환경의 함정

문제는 @WebMvcTest가 전체 애플리케이션 컨텍스트를 로드하지 않는 '슬라이스 테스트'라는 점에서 발생합니다. 이 환경에서는 MVC 동작에 필수적인 최소한의 빈들만 등록됩니다. 완전한 WebMvcConfigurationSupport나 사용자가 정의한 커스텀 WebMvcConfigurer 설정이 모두 적용되지 않을 수 있습니다.

이로 인해, @WebMvcTest의 기본 설정에서는 String[]List<String>으로 유연하게 변환해주는 ConversionService의 일부 기능이 활성화되지 않거나 다른 방식으로 동작할 수 있습니다.

따라서 데이터 바인더는 목표 타입인 List<String>을 보고, 이 컬렉션을 직접 생성하여 값을 채우려고 시도하게 됩니다. 이를 위해 자바의 리플렉션(Reflection) API를 사용하여 `List.class.getConstructor()`와 같은 코드를 내부적으로 호출합니다. 하지만 java.util.List는 인터페이스(Interface)이므로 인스턴스화할 수 있는 생성자(<init>)가 존재하지 않습니다. 이것이 바로 NoSuchMethodException이 발생하는 근본적인 이유입니다. Spring의 테스트 환경 데이터 바인더가 구체적인 List 구현체(e.g., ArrayList)를 선택하지 못하고, 인터페이스 자체를 생성하려고 시도하다가 실패하는 것입니다.

반면, 전체 애플리케이션을 실행했을 때 정상 동작했던 이유는, 전체 컨텍스트에서는 모든 자동 설정이 적용되어 ConversionServiceString[]을 `ArrayList<String>`과 같은 구체적인 컬렉션으로 변환하는 로직을 정상적으로 수행했기 때문입니다.


이제 원인을 명확히 알았으니, 해결책을 찾아봅시다. 해결책은 한 가지만 있는 것이 아닙니다. 상황의 복잡도와 프로젝트의 설계 원칙에 따라 여러 가지 접근법을 취할 수 있습니다.

해결 방안 1: 배열(Array) 사용 (가장 간단한 해결책)

가장 즉각적이고 간단한 해결책은 컨트롤러의 파라미터 타입을 List<String>에서 String[] (문자열 배열)으로 변경하는 것입니다.


// 수정 전
// public ResponseEntity<String> searchByKeywords(List<String> keywords) { ... }

// 수정 후
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(String[] keywords) {
    if (keywords == null || keywords.length == 0) {
        return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
    }
    String result = "검색된 키워드: " + String.join(", ", keywords);
    // 필요하다면 내부에서 List로 변환하여 사용 가능
    // List<String> keywordList = Arrays.asList(keywords);
    return ResponseEntity.ok(result);
}

왜 이것이 동작할까요?
데이터 바인더는 요청 파라미터를 기본적으로 String[] 형태로 수집합니다. 컨트롤러 메서드의 파라미터 타입이 이와 동일한 String[] 이므로, 별도의 타입 '변환' 과정이 필요 없게 됩니다. 인터페이스를 생성하려는 시도 자체가 일어나지 않기 때문에 NoSuchMethodException이 발생할 여지가 사라집니다. 만약 내부 로직에서 List의 메서드(stream, forEach 등)를 사용해야 한다면, Arrays.asList(keywords)를 통해 간단하게 List로 변환하여 사용할 수 있습니다.

이 방법은 매우 간단하고 직관적이지만, 자바 컬렉션 프레임워크의 사용을 선호하는 최신 자바 스타일에서는 배열을 직접 사용하는 것이 다소 어색하게 느껴질 수 있다는 단점이 있습니다.

해결 방안 2: @RequestParam 어노테이션 명시

많은 경우, 컨트롤러 파라미터에 @RequestParam 어노테이션을 명시적으로 붙여주는 것만으로도 문제가 해결됩니다.


import org.springframework.web.bind.annotation.RequestParam;
...

// 수정 전
// public ResponseEntity<String> searchByKeywords(List<String> keywords) { ... }

// 수정 후
@GetMapping("/api/search")
public ResponseEntity<String> searchByKeywords(@RequestParam List<String> keywords) {
    if (keywords.isEmpty()) {
        return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
    }
    String result = "검색된 키워드: " + String.join(", ", keywords);
    return ResponseEntity.ok(result);
}

왜 이것이 동작할까요?
컨트롤러 메서드의 파라미터에 어떠한 어노테이션도 붙이지 않으면 Spring MVC는 여러 HandlerMethodArgumentResolver 중 어떤 것을 사용해야 할지 추론 과정을 거칩니다. 이 과정에서 @WebMvcTest의 미니멀한 환경에서는 앞서 설명한 문제가 발생할 수 있습니다.

하지만 @RequestParam을 명시적으로 선언하면, Spring MVC에게 "이 파라미터는 HTTP 요청의 쿼리 파라미터(또는 form data)로부터 값을 가져와야 한다"는 강력한 힌트를 주게 됩니다. 이는 RequestParamMethodArgumentResolver가 활성화되도록 강제하는 효과가 있습니다. 이 리졸버는 내부적으로 컬렉션 타입 바인딩을 처리하기 위한 더 정교한 로직과 `ConversionService`와의 연계를 포함하고 있어, `List` 인터페이스를 만나면 `ArrayList`와 같은 적절한 구체 클래스를 사용하여 값을 채워 넣을 수 있게 됩니다.

사실, 파라미터 이름이 메서드 변수명과 같더라도 @RequestParam을 붙이는 것은 코드의 명시성을 높이고 의도를 명확하게 하는 좋은 습관이므로, 많은 경우에 이 방법을 기본으로 사용하는 것을 권장합니다.

해결 방안 3: DTO(Data Transfer Object) 사용 (가장 권장되는 방식)

요청 파라미터가 2개를 넘어가거나, 연관된 데이터들을 함께 받아야 하거나, 유효성 검사(Validation)가 필요한 경우, 파라미터를 개별적으로 나열하는 것보다 DTO(또는 Command 객체)로 묶어서 처리하는 것이 소프트웨어 공학적으로 훨씬 우수합니다. 놀랍게도 이 설계 패턴은 NoSuchMethodException 문제에 대한 가장 확실한 해결책이기도 합니다.

1. 요청 파라미터를 담을 DTO 클래스 작성


import java.util.List;

public class SearchRequest {

    private List<String> keywords;
    
    // Lombok을 사용한다면 @Getter, @Setter, @NoArgsConstructor 등으로 대체 가능
    public List<String> getKeywords() {
        return keywords;
    }

    public void setKeywords(List<String> keywords) {
        this.keywords = keywords;
    }
}

2. DTO를 사용하도록 컨트롤러 수정 (@ModelAttribute)


import org.springframework.web.bind.annotation.ModelAttribute;

@RestController
public class SearchController {

    @GetMapping("/api/search")
    public ResponseEntity<String> searchByKeywords(@ModelAttribute SearchRequest request) {
        List<String> keywords = request.getKeywords();
        if (keywords == null || keywords.isEmpty()) {
            return ResponseEntity.badRequest().body("키워드를 하나 이상 입력해주세요.");
        }
        String result = "검색된 키워드: " + String.join(", ", keywords);
        return ResponseEntity.ok(result);
    }
}

GET 요청에서 객체로 파라미터를 바인딩할 때는 @ModelAttribute를 사용합니다. (참고: @RequestBody는 요청의 본문(body)을 객체로 변환할 때 사용하며, 주로 POST, PUT 요청의 JSON 데이터에 사용됩니다. @ModelAttribute는 쿼리 파라미터나 form 데이터를 객체의 필드에 바인딩할 때 사용됩니다.)

3. 테스트 코드 (변경 없음)

놀랍게도, 컨트롤러가 DTO를 사용하도록 변경되어도 테스트 코드는 전혀 변경할 필요가 없습니다. MockMvc.param() 메서드는 여전히 동일하게 동작합니다.


    @Test
    void 키워드_리스트로_검색_테스트_DTO사용() throws Exception {
        // 컨트롤러의 파라미터가 DTO로 바뀌었지만 테스트 코드는 동일하다.
        mockMvc.perform(get("/api/search")
                        .param("keywords", "spring", "jpa", "restdocs"))
                .andExpect(status().isOk())
                .andDo(document("search-by-keywords-dto",
                        queryParameters(
                                // 문서화 대상이 DTO의 필드명이 된다.
                                parameterWithName("keywords").description("검색할 키워드 목록 (다중값 가능)")
                        )
                ));
    }

왜 DTO 방식은 완벽하게 동작할까요?
@ModelAttribute를 사용한 객체 바인딩은 @RequestParam을 사용한 개별 파라미터 바인딩과 다른 메커니즘을 사용합니다. Spring MVC는 다음과 같은 절차를 따릅니다.

  1. 먼저 DTO 객체(SearchRequest)의 인스턴스를 생성합니다. 이때는 DTO 클래스의 기본 생성자를 사용하므로 아무런 문제가 없습니다.
  2. 그 후, 요청 파라미터들을 DTO 객체의 필드에 하나씩 채워 넣습니다. 'keywords' 라는 요청 파라미터를 발견하면 `SearchRequest` 객체의 `setKeywords()` 메서드를 호출하려고 합니다.
  3. 이때 `setKeywords()` 메서드의 파라미터 타입이 List<String>인 것을 확인하고, String[] 형태의 요청 파라미터 값들을 `List<String>`으로 변환합니다. 객체의 필드에 값을 설정하는 이 과정에서는 ConversionService가 올바르게 동작하여, 구체적인 List 구현체(ArrayList)를 생성하고 값을 채워 넣어 `setKeywords` 메서드를 성공적으로 호출합니다.

즉, 인터페이스인 `List`를 직접 생성하려는 위험한 시도 대신, DTO 객체를 먼저 생성하고 그 내부 필드를 채우는 안전한 방식으로 동작하기 때문에 문제가 발생하지 않습니다. 또한, DTO를 사용하면 향후 확장성(새로운 검색 조건 추가), 유효성 검사(@Valid, @Size 등), 코드의 가독성 및 유지보수성 측면에서 압도적인 이점을 가집니다.

종합: 어떤 해결책을 선택해야 할까?

지금까지 세 가지 해결책을 살펴보았습니다. 각 방법의 장단점을 정리하고 어떤 상황에 어떤 방법을 선택해야 할지 가이드를 제시합니다.

해결 방안 장점 단점 추천 상황
1. 배열 (String[]) 가장 간단하고 즉각적인 해결책. 데이터 바인딩 원리를 몰라도 적용 가능. 현대 자바 스타일에 다소 어울리지 않음. List의 풍부한 API를 사용하려면 변환 필요. 급하게 버그를 수정해야 할 때, 또는 프로젝트 전반적으로 배열 사용에 거부감이 없는 경우.
2. @RequestParam List<...> 코드의 의도가 명확해짐. List를 그대로 사용할 수 있어 Java 컬렉션 친화적. 특정 Spring 버전이나 복잡한 설정 조합에서는 여전히 문제가 발생할 가능성을 배제할 수 없음. 간단한 파라미터 한두 개를 받을 때. 가장 먼저 시도해볼 만한 균형 잡힌 방법.
3. DTO와 @ModelAttribute 가장 안정적이고 근본적인 해결책. 확장성, 유효성 검사, 가독성 등 장점이 많음. 단일 파라미터를 위해 DTO 클래스를 하나 더 만들어야 하는 약간의 번거로움. 대부분의 실무 애플리케이션에 권장. 요청 파라미터가 2개 이상이거나, 앞으로 확장될 가능성이 있거나, 유효성 검사가 필요한 모든 경우.

결론

Spring REST Docs 테스트 중 발생하는 java.lang.NoSuchMethodException: java.util.List.<init>() 오류는 @WebMvcTest의 슬라이스 테스트 환경에서 Spring MVC 데이터 바인더가 List 인터페이스를 직접 인스턴스화하려고 시도하기 때문에 발생합니다. 이는 테스트 환경의 `ConversionService` 설정이 전체 애플리케이션 환경과 미묘하게 다르기 때문입니다.

이 문제에 대한 해결책으로 우리는 파라미터 타입을 String[]으로 변경하는 가장 간단한 방법, @RequestParam 어노테이션을 명시하여 바인딩 힌트를 주는 방법, 그리고 DTO를 사용하여 구조적으로 문제를 해결하는 가장 강력하고 권장되는 방법을 알아보았습니다.

단순히 에러를 해결하는 것을 넘어 그 원인을 깊이 있게 파고드는 과정은 Spring 프레임워크의 내부 동작에 대한 이해를 높이는 좋은 기회가 됩니다. 당장의 문제를 해결하기 위해 배열을 사용하는 것도 방법일 수 있지만, 장기적인 관점에서는 코드의 의도를 명확히 하는 @RequestParam을 사용하거나, 더 나아가 DTO를 도입하여 애플리케이션의 설계 품질을 높이는 방향을 지향하는 것이 바람직합니다. 이제 이 성가신 예외를 마주치더라도 당황하지 않고, 원인을 자신있게 설명하며 최적의 해결책을 적용할 수 있는 개발자로 거듭나시기를 바랍니다.