| name | junit |
| description | [Applies to: **/*.java] This guide provides definitive, opinionated best practices for writing high-quality, maintainable, and effective unit tests using JUnit 5 (Jupiter) in Java projects. |
| source | cursor_mdc |
junit Best Practices
JUnit 5 (Jupiter) is the bedrock of modern Java unit testing. This guide outlines the essential practices for crafting robust, readable, and reliable tests that integrate seamlessly into your development workflow. Adhere to these rules to ensure your tests are a valuable asset, not a burden.
1. Code Organization and Structure
1.1. Test File and Package Naming
Always mirror your production code's package structure within src/test/java. Name test classes by appending Test to the class they verify.
❌ BAD:
package com.example;
class UserServiceLogic { }
✅ GOOD:
package com.example;
class UserService { }
package com.example;
class UserServiceTest { }
1.2. Descriptive Test Method Naming
Test method names must clearly convey their purpose: the scenario being tested, the action, and the expected outcome. Use the Given_When_Then pattern or methodName_condition_expectedResult. For enhanced readability in test reports, leverage @DisplayName.
❌ BAD:
@Test
void testAdd() { }
@Test
void calculateDiscount() { }
✅ GOOD:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class CalculatorTest {
@Test
@DisplayName("Given two positive numbers, when added, then return their sum")
void add_TwoPositiveNumbers_ReturnsSum() {
}
@Test
void calculateDiscount_PremiumCustomer_Applies20PercentDiscount() {
}
}
1.3. Logical Grouping with @Nested
For complex classes with many behaviors, use @Nested classes to group related tests. This improves readability and organization, especially when dealing with different states or conditions.
❌ BAD:
class OrderServiceTest {
@Test void createOrder_ValidInput_ReturnsOrder() { }
@Test void createOrder_InvalidProduct_ThrowsException() { }
@Test void cancelOrder_ExistingOrder_SetsStatusToCancelled() { }
@Test void cancelOrder_NonExistingOrder_ThrowsException() { }
}
✅ GOOD:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class OrderServiceTest {
@Nested
@DisplayName("When creating an order")
class CreateOrder {
@Test
void givenValidInput_thenReturnsOrder() { }
@Test
void givenInvalidProduct_thenThrowsException() { }
}
@Nested
@DisplayName("When cancelling an order")
class CancelOrder {
@Test
void givenExistingOrder_thenSetsStatusToCancelled() { }
@Test
void givenNonExistingOrder_thenThrowsException() { }
}
}
2. Common Patterns and Anti-patterns
2.1. Follow Arrange-Act-Assert (AAA)
Structure every test method into three distinct sections:
- Arrange: Set up the test data, mocks, and the system under test.
- Act: Execute the method being tested.
- Assert: Verify the outcome using JUnit assertions.
This pattern enhances readability and makes it clear what each part of the test is doing.
❌ BAD:
@Test
void processPayment_InsufficientFunds_ThrowsException() {
BankAccount account = new BankAccount(100);
PaymentProcessor processor = new PaymentProcessor();
try {
processor.process(account, 200);
fail("Expected InsufficientFundsException");
} catch (InsufficientFundsException e) {
assertTrue(e.getMessage().contains("Insufficient funds"));
}
}
✅ GOOD:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PaymentProcessorTest {
@Test
void processPayment_InsufficientFunds_ThrowsException() {
BankAccount account = new BankAccount(100);
PaymentProcessor processor = new PaymentProcessor();
InsufficientFundsException thrown = assertThrows(
InsufficientFundsException.class,
() -> processor.process(account, 200),
"Expected process() to throw InsufficientFundsException"
);
assertTrue(thrown.getMessage().contains("Insufficient funds"));
}
}
2.2. Single Logical Assertion Per Test
Each test should verify a single behavior. While you might use multiple assert* calls, they should collectively validate one logical outcome. This makes debugging easier: if a test fails, you know exactly which behavior is broken.
❌ BAD:
@Test
void updateUser_ValidData_UpdatesUserAndLogsActivity() {
User user = new User("old@example.com");
userService.updateUser(user, "new@example.com");
assertEquals("new@example.com", user.getEmail());
assertTrue(activityLog.contains("User updated"));
}
✅ GOOD:
@Test
void updateUser_ValidData_UpdatesUserEmail() {
User user = new User("old@example.com");
userService.updateUser(user, "new@example.com");
assertEquals("new@example.com", user.getEmail());
}
@Test
void updateUser_ValidData_LogsUserActivity() {
User user = new User("old@example.com");
userService.updateUser(user, "new@example.com");
assertTrue(activityLog.contains("User updated"));
}
2.3. Parameterized Tests for Data-Driven Scenarios
Avoid duplicating test logic for different inputs. Use @ParameterizedTest with @ValueSource, @CsvSource, or @MethodSource to run the same test logic with various data sets.
❌ BAD:
@Test
void isEven_Two_ReturnsTrue() { assertTrue(calculator.isEven(2)); }
@Test
void isEven_Four_ReturnsTrue() { assertTrue(calculator.isEven(4)); }
@Test
void isEven_Odd_ReturnsFalse() { assertFalse(calculator.isEven(3)); }
✅ GOOD:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 5, 15"
})
void add_VariousInputs_ReturnsCorrectSum(int a, int b, int expectedSum) {
Calculator calculator = new Calculator();
int actualSum = calculator.add(a, b);
assertEquals(expectedSum, actualSum);
}
}
3. Performance Considerations
3.1. Keep Tests Fast and Isolated
Unit tests must run quickly and deterministically. Avoid any operations that introduce slowness or flakiness.
❌ BAD:
@Test
void processExternalService_Success_ReturnsData() {
ExternalService service = new ExternalService();
String result = service.getData();
assertNotNull(result);
}
@Test
void waitForAsyncOperation_Completes_ReturnsResult() throws InterruptedException {
Thread.sleep(1000);
assertTrue(asyncService.isCompleted());
}
✅ GOOD:
@Test
void processData_ValidInput_ReturnsProcessedData() {
DataProcessor processor = new DataProcessor(mockDependency);
String result = processor.process("input");
assertEquals("processed_input", result);
}
3.2. Use @Tag for Filtering
Categorize tests by speed, type, or module using @Tag. This allows you to run subsets of tests (e.g., "fast" tests on every commit, "slow" integration tests less frequently).
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
class UserServiceTest {
@Test
@Tag("fast")
void createUser_ValidUser_ReturnsCreatedUser() { }
@Test
@Tag("slow")
@Tag("integration")
void integrateWithDatabase_SavesUser_RetrievesUser() { }
}
4. Common Pitfalls and Gotchas
4.1. Avoid Logic Duplication in Tests
Never duplicate the logic you're testing within your test code. Hard-code expected results to ensure the test fails when the implementation is incorrect.
❌ BAD:
@Test
void calculateArea_Circle_ReturnsCorrectArea() {
double radius = 5.0;
double expectedArea = Math.PI * radius * radius;
assertEquals(expectedArea, geometry.calculateCircleArea(radius));
}
✅ GOOD:
@Test
void calculateArea_Circle_ReturnsCorrectArea() {
double radius = 5.0;
double actualArea = geometry.calculateCircleArea(radius);
assertEquals(78.53981633974483, actualArea, 0.0001);
}
4.2. Flaky Tests (External Dependencies, System Date)
Tests must be deterministic. Avoid reliance on external services, databases, network calls, or system time/date. Mock these dependencies to ensure consistent results.
❌ BAD:
@Test
void generateReport_CurrentDate_IncludesToday() {
String report = reportGenerator.generate();
assertTrue(report.contains(LocalDate.now().toString()));
}
✅ GOOD:
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReportGeneratorTest {
@Test
void generateReport_FixedDate_IncludesExpectedDate() {
Clock fixedClock = Clock.fixed(Instant.parse("2025-01-15T10:00:00Z"), ZoneId.of("UTC"));
ReportGenerator reportGenerator = new ReportGenerator(fixedClock);
String report = reportGenerator.generate();
assertTrue(report.contains("2025-01-15"));
}
}
5. Mocking Strategies
5.1. Replace Heavyweight Dependencies with Mocks
Use a mocking framework (e.g., Mockito) to isolate the unit under test from its dependencies. This keeps tests fast, focused, and prevents side effects.
❌ BAD:
class UserService {
private final DatabaseRepository repository = new DatabaseRepository();
}
@Test
void createUser_ValidUser_SavesToDatabase() {
UserService userService = new UserService();
User user = new User("test@example.com");
userService.createUser(user);
User savedUser = retrieveFromRealDatabase(user.getEmail());
assertNotNull(savedUser);
}
✅ GOOD:
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class UserServiceTest {
@Mock
private DatabaseRepository databaseRepository;
@InjectMocks
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void createUser_ValidUser_SavesUserViaRepository() {
User user = new User("test@example.com");
when(databaseRepository.save(any(User.class))).thenReturn(user);
User createdUser = userService.createUser(user);
assertNotNull(createdUser);
verify(databaseRepository, times(1)).save(user);
}
}
6. Coverage Patterns
6.1. Focus on Meaningful Coverage
Aim for high, but meaningful, code coverage. Don't chase 100% coverage with superficial tests. Prioritize testing critical paths, boundary conditions, and error handling.
❌ BAD:
@Test
void divide_PositiveNumbers_ReturnsQuotient() {
assertEquals(2, calculator.divide(4, 2));
}
✅ GOOD:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CalculatorTest {
@Test
void divide_PositiveNumbers_ReturnsQuotient() {
assertEquals(2, calculator.divide(4, 2));
}
@Test
void divide_ByZero_ThrowsArithmeticException() {
assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0));
}
@Test
void divide_NegativeNumbers_ReturnsCorrectQuotient() {
assertEquals(-2, calculator.divide(-4, 2));
}
}