Testing in Java used to feel like a chore to me. A necessary step, sure, but often clunky and filled with repetitive code. That changed with JUnit 5. It transformed testing from a verification checklist into a powerful part of how I design and think about my code. I want to share the techniques that made the biggest difference in my daily work, explained as if we’re pairing at the same computer.
Let’s start with something simple but impactful: test names. We’ve all seen test methods named testAdd1 or similar. What does that even mean? JUnit 5 introduces the @DisplayName annotation. This lets you write a full, human-readable sentence to describe your test. The test report becomes a story about what your code does, not a cryptic list of method names.
@Test
@DisplayName("When adding two positive integers, the result should be their sum")
void addingPositiveIntegersYieldsSum() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
When this test fails, the message is immediately clear. I use this on every single test I write now. It forces me to articulate the expected behavior, which often clarifies my thinking about the production code itself.
One of my biggest headaches was testing the same logic with multiple inputs. I’d copy and paste a test method ten times, changing one value each time. It was a maintenance nightmare. Parameterized tests solve this. You write the test logic once and provide a stream of inputs. JUnit 5 runs the test for each one.
@ParameterizedTest
@ValueSource(ints = {1, 5, 100, 10000})
void isPositive_returnsTrueForPositiveNumbers(int number) {
NumberValidator validator = new NumberValidator();
assertTrue(validator.isPositive(number));
}
But it gets better. You can use @CsvSource for more complex, multi-argument cases. I use this constantly for testing validation rules or state transitions.
@ParameterizedTest
@CsvSource({
"10, 3, 13",
"0, 0, 0",
"-5, 10, 5",
"100, -20, 80"
})
void add_returnsCorrectSum(int a, int b, int expectedSum) {
Calculator calc = new Calculator();
assertEquals(expectedSum, calc.add(a, b));
}
Sometimes, you need to run a test multiple times, not with different inputs, but to see if a non-deterministic behavior eventually fails or to gauge performance. That’s where @RepeatedTest comes in. I’ve used this to check that a random ID generator doesn’t produce duplicates over thousands of iterations or to ensure a cache behaves correctly under repeated access.
@RepeatedTest(value = 100, name = "Attempt {currentRepetition} of {totalRepetitions}")
void generatedIdShouldBeUnique(RepetitionInfo info) {
String id = idGenerator.generate();
assertFalse(previousIds.contains(id), "Failed on repetition " + info.getCurrentRepetition());
previousIds.add(id);
}
The RepetitionInfo parameter is a nice touch, letting you access the current iteration count for detailed failure messages or logging.
Now, what if you don’t even know all your test cases at compile time? Perhaps they come from a file, a database, or are generated by an algorithm. This is where dynamic tests feel like magic. Instead of @Test, you use @TestFactory to return a collection or stream of DynamicTest objects. Each one is fabricated at runtime.
I used this to create tests for every file in a directory of configuration samples. It was incredibly powerful.
@TestFactory
Stream<DynamicTest> testEveryConfigurationFile() throws IOException {
Path configDir = Paths.get("src/test/configs");
return Files.list(configDir)
.map(path -> DynamicTest.dynamicTest(
"Validating " + path.getFileName(),
() -> {
String content = Files.readString(path);
assertTrue(configValidator.isValid(content),
"File " + path + " contains invalid configuration.");
}
));
}
As your test class for a complex component grows, it can become a disorganized list of methods. Nested test classes help you impose a logical structure. Using @Nested, you can group tests that share a common setup or state. The test output becomes an outline of your component’s behavior.
I structure mine like a specification: “When the account is new”, “When the account has a negative balance”, and so on.
@DisplayName("Bank Account")
class BankAccountTest {
private BankAccount account;
@Nested
@DisplayName("When the account is freshly opened")
class NewAccount {
@BeforeEach
void createAccount() {
account = new BankAccount(100.00); // Opening balance
}
@Test
void hasCorrectOpeningBalance() {
assertEquals(100.00, account.getBalance());
}
@Test
void allowsValidWithdrawal() {
account.withdraw(30.00);
assertEquals(70.00, account.getBalance());
}
}
@Nested
@DisplayName("When the account is overdrawn")
class OverdrawnAccount {
@BeforeEach
void createAndOverdrawAccount() {
account = new BankAccount(50.00);
account.withdraw(75.00); // Puts balance at -25.00
}
@Test
void hasNegativeBalance() {
assertTrue(account.getBalance() < 0);
}
@Test
void chargesOverdraftFeeOnFurtherWithdrawal() {
account.withdraw(10.00);
// Asserts fee was applied, making balance less than -35.00
assertTrue(account.getBalance() < -35.00);
}
}
}
Testing for exceptions is a common need. The old @Test(expected = ...) was okay, but it couldn’t inspect the exception. JUnit 5’s assertThrows is far superior. It returns the exception, letting you verify its message, cause, or other properties. My tests for validation logic are now much more precise.
@Test
void withdraw_throwsExceptionForNegativeAmount() {
BankAccount account = new BankAccount(100.00);
IllegalArgumentException thrownException = assertThrows(
IllegalArgumentException.class,
() -> account.withdraw(-10.00) // Attempt invalid withdrawal
);
// Now we can assert on the exception details
assertEquals("Withdrawal amount must be positive", thrownException.getMessage());
}
Most applications rely on dependencies like databases or external services. Testing in isolation requires mocks. JUnit 5 integrates with mocking frameworks through its extension model. Instead of setting up mocks manually, you use an extension to handle it. With Mockito, it’s beautifully simple.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway; // This will be a mock
@Mock
private InventoryService inventoryService; // This will be a mock
@InjectMocks
private OrderService orderService; // Mocks are injected here
@Test
void placeOrder_succeedsWhenPaymentAndInventoryAreValid() {
// Given
Order order = new Order("order123", 50.00);
when(paymentGateway.process(50.00)).thenReturn(new PaymentResult(true));
when(inventoryService.reserveItems(order)).thenReturn(true);
// When
boolean success = orderService.placeOrder(order);
// Then
assertTrue(success);
verify(paymentGateway).process(50.00);
verify(inventoryService).reserveItems(order);
}
}
The @ExtendWith annotation tells JUnit to use Mockito’s machinery. @Mock creates mock objects. @InjectMocks creates the real OrderService and injects the mock dependencies into it. This setup keeps my test code clean and focused on behavior.
Some operations should be fast. A slow test might indicate a performance issue or a hung process. The @Timeout annotation is a built-in watchdog for your test. I add this to any test that calls an external API or does file I/O. If it takes too long, I want the test to fail fast, not hang my build pipeline.
@Test
@Timeout(5) // Fails if test takes longer than 5 seconds
void fetchingUserProfileFromApi_completesInReasonableTime() {
UserProfile profile = externalApiClient.fetchProfile("user123");
assertNotNull(profile.getUserId());
}
In a large project, you have different kinds of tests. Fast unit tests, slower integration tests, and maybe very slow end-to-end tests. Running them all every time you save a file is painful. Tagging lets you categorize tests. You can then run only the fast ones during development and all of them during the nightly build.
@Test
@Tag("integration") // This is an integration test
void shouldPersistDataToDatabase() {
// ... test logic that uses a real database
}
@Test
@Tag("fast") // This is a fast unit test
void shouldCalculateTotalCorrectly() {
// ... pure logic test
}
You can then run tests from the command line: mvn test -Dgroups="fast" or ./gradlew test --tests "*Test" -PincludeTags="fast".
Finally, a simple trick that improves test reporting. Often, you have multiple assertions in a test. If the first one fails, JUnit stops, and you don’t know if the others would have passed. assertAll groups assertions and executes them all, reporting every failure together. This gives a complete picture of what’s wrong.
I use this when testing a complex object’s state after an operation.
@Test
void createUser_initializesAllFieldsCorrectly() {
UserService service = new UserService();
User newUser = service.createUser("alice", "[email protected]");
assertAll("User creation",
() -> assertNotNull(newUser.getId(), "ID should be generated"),
() -> assertEquals("alice", newUser.getUsername(), "Username mismatch"),
() -> assertEquals("[email protected]", newUser.getEmail(), "Email mismatch"),
() -> assertTrue(newUser.isActive(), "New user should be active"),
() -> assertNotNull(newUser.getCreatedAt(), "Creation timestamp is missing")
);
}
If the email is wrong and the user is inactive, I’ll learn both facts from a single test run. It’s a huge time saver.
These techniques, taken together, have reshaped how I approach Java development. Testing is no longer a separate phase. It’s a core activity that guides design, documents behavior, and provides immediate feedback. JUnit 5 provides the tools to make those tests clear, maintainable, and a genuine asset to the development process. Start with descriptive names and grouped assertions, then gradually incorporate parameterized and dynamic tests. The structure and confidence they bring to a codebase are immediately worthwhile.