@MethodSource for Complex Data

9 min read

@CsvSource and @CsvFileSource work well when your data is flat — strings, numbers, booleans. The moment you need to pass a fully constructed object as a parameter — a User with five fields, an HttpRequest with headers — CSV cannot represent it. @MethodSource is the answer: reference a factory method that builds and returns whatever types your test needs. It is the JUnit 5 equivalent of TestNG's @DataProvider, and it is the most flexible source annotation.

The basic pattern

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.Arguments;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
 
class UserServiceTest {
 
    @ParameterizedTest
    @MethodSource("loginTestData")
    void testLogin(String email, String password, int expectedStatus) {
        Response response = apiClient.login(email, password);
        assertEquals(expectedStatus, response.getStatusCode());
    }
 
    static Stream<Arguments> loginTestData() {
        return Stream.of(
            Arguments.of("admin@test.com", "AdminPass123", 200),
            Arguments.of("user@test.com",  "UserPass123",  200),
            Arguments.of("wrong@test.com", "BadPass",      401),
            Arguments.of("",               "password",     400),
            Arguments.of(null,             "password",     400)
        );
    }
}

The factory method must be static by default (or non-static with @TestInstance(Lifecycle.PER_CLASS)). It returns Stream<Arguments> — each Arguments.of(...) becomes one test invocation. Arguments.of accepts any number of values of any type.

Passing complex objects

This is where @MethodSource outshines CSV-based sources. You can construct real objects with builders, set specific fields, and control edge cases precisely:

@ParameterizedTest
@MethodSource("userCreationScenarios")
void testCreateUser(User user, int expectedStatus, String expectedError) {
    Response response = apiClient.createUser(user);
    assertEquals(expectedStatus, response.getStatusCode());
 
    if (expectedError != null) {
        assertEquals(expectedError, response.jsonPath().getString("error"));
    }
}
 
static Stream<Arguments> userCreationScenarios() {
    return Stream.of(
        Arguments.of(
            new User("Alice", "alice@test.com", "admin"),
            201, null
        ),
        Arguments.of(
            new User("", "bob@test.com", "tester"),
            400, "Name cannot be empty"
        ),
        Arguments.of(
            new User("Charlie", "", "tester"),
            400, "Email cannot be empty"
        ),
        Arguments.of(
            new User("Dave", "not-an-email", "tester"),
            400, "Email format is invalid"
        )
    );
}

No CSV can represent new User(...). @MethodSource can. This mirrors TestNG's @DataProvider returning Object[][] with pre-built objects — the difference is that JUnit's factory method returns a typed Stream rather than an untyped array.

Implicit method name matching

When the factory method has the same name as the test method, you can omit the value from @MethodSource:

@ParameterizedTest
@MethodSource   // looks for: static Stream<Arguments> testLogin()
void testLogin(String email, String password) { ... }
 
static Stream<Arguments> testLogin() {
    return Stream.of(
        Arguments.of("admin@test.com", "AdminPass123"),
        Arguments.of("user@test.com",  "UserPass123")
    );
}

This is a convention for keeping the factory close to its test. It works cleanly for small test classes. In larger test suites with shared data, an explicit name is clearer.

Factory method in an external class

Shared test data should live in one place. Put the factory in a dedicated data class and reference it by fully qualified method name:

// src/test/java/com/mycompany/testdata/LoginData.java
package com.mycompany.testdata;
 
public class LoginData {
 
    public static Stream<Arguments> validCredentials() {
        return Stream.of(
            Arguments.of("admin@test.com", "AdminPass123", 200),
            Arguments.of("user@test.com",  "UserPass123",  200)
        );
    }
 
    public static Stream<Arguments> invalidCredentials() {
        return Stream.of(
            Arguments.of("wrong@test.com", "BadPass", 401),
            Arguments.of("",              "password", 400)
        );
    }
}

Reference them in tests across the project:

@ParameterizedTest
@MethodSource("com.mycompany.testdata.LoginData#validCredentials")
void validLoginReturns200(String email, String password, int expected) { ... }
 
@ParameterizedTest
@MethodSource("com.mycompany.testdata.LoginData#invalidCredentials")
void invalidLoginIsRejected(String email, String password, int expected) { ... }

In TestNG, a @DataProvider in a separate class requires dataProviderClass = LoginData.class. The JUnit equivalent is the fully qualified ClassName#methodName string. Both require the method to be static.

Single-parameter shorthand

When the test has only one parameter, the factory can return a Stream of values directly — no Arguments.of wrapping needed:

@ParameterizedTest
@MethodSource("validEmails")
void shouldAcceptValidEmail(String email) {
    assertTrue(EmailValidator.isValid(email));
}
 
static Stream<String> validEmails() {
    return Stream.of(
        "alice@test.com",
        "bob@domain.org",
        "charlie@example.co.uk"
    );
}

For single-parameter tests you can use @ValueSource instead — @MethodSource becomes worth it when you need to compute the values (read from a file, query a database, call a builder).

When to use which source

ScenarioBest source
A handful of simple strings or ints@ValueSource
Null/empty boundary cases@NullAndEmptySource
Multi-column data, 3–15 rows@CsvSource
Multi-column data, 20+ rows@CsvFileSource
Complex objects (User, HttpRequest)@MethodSource
Computed values (database, API)@MethodSource
Every constant of an enum@EnumSource

@MethodSource data flow

⚠️ Common mistakes

  • Non-static factory method without @TestInstance(PER_CLASS). @MethodSource calls the factory before any test instance is created. Without PER_CLASS, JUnit cannot call an instance method. The error is org.junit.jupiter.api.extension.ExtensionConfigurationException: ... must be static unless.... Add static to the factory, or add @TestInstance(Lifecycle.PER_CLASS) to the test class.
  • Factory returns null or an empty stream. If loginTestData() returns Stream.empty(), JUnit runs zero tests with no warning — the test simply disappears from the report. If you see a test method name in the report with 0 executions, check that the factory returns at least one Arguments.of(...).
  • Misspelling the factory method name in @MethodSource. @MethodSource("loginTestdata") (lowercase d) fails at runtime with Could not find factory method [loginTestdata]. The name is case-sensitive. Use the implicit-name form (no string) when factory and test have the same name — eliminates the typo risk entirely.

🎯 Practice task

Replace a @DataProvider pattern with @MethodSource. 25–35 minutes.

  1. Create a ProductService with a method applyDiscount(Product product, double percent) that returns a double. Throw IllegalArgumentException for negative percentages or null products.
  2. Write ProductServiceTest with:
    • A @MethodSource("discountScenarios") test that passes Product objects and percentages — assert the expected discounted price within 0.01 delta.
    • A @MethodSource("invalidDiscountScenarios") test that asserts assertThrows for each invalid input.
  3. Move the factory methods to an external ProductTestData class. Use the fully qualified "com.mycompany.testdata.ProductTestData#discountScenarios" reference.
  4. Single-parameter shorthand. Write a @MethodSource("productNames") test using a factory that returns Stream<String> (not Stream<Arguments>). Confirm the test receives a plain String parameter.
  5. Stretch — computed data. In the factory, build your test data programmatically: use IntStream.rangeClosed(1, 5).mapToObj(i -> Arguments.of(new Product("Product " + i, i * 10.0), 10.0, i * 9.0)) to generate five products in a loop. Confirm five tests run.

Next lesson: @RepeatedTest — running a test multiple times to detect flakiness and measure consistency.

// tip to track lessons you complete and pick up where you left off across devices.