Clean JUnit tests should be easy to understand, focused on one behavior, and safe to change. A good test should tell you:
- What scenario is being tested
- What action happens
- What result is expected
Most Java projects today should use JUnit 5, from the org.junit.jupiter package.
1. Use Clear Test Names
A test name should describe the behavior being tested.
Prefer names like:
@Test
void depositIncreasesAccountBalance() {
// test body
}
Instead of vague names like:
@Test
void testDeposit() {
// test body
}
Good test names often follow this style:
methodName_expectedResult_whenCondition
Example:
@Test
void withdrawThrowsExceptionWhenBalanceIsInsufficient() {
// test body
}
Or a more natural sentence style:
@Test
void cannotWithdrawMoreThanCurrentBalance() {
// test body
}
2. Follow the Arrange-Act-Assert Pattern
A readable test usually has three sections:
| Section | Purpose |
|---|---|
| Arrange | Prepare test data and objects |
| Act | Execute the behavior being tested |
| Assert | Verify the result |
Example:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void addReturnsSumOfTwoNumbers() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.add(2, 3);
// Assert
assertEquals(5, result);
}
}
You do not always need the comments, especially if the test is already short and obvious. But the structure should still be clear.
3. Test One Behavior Per Test
Each test should focus on one specific behavior.
Good:
@Test
void addReturnsSumOfTwoPositiveNumbers() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result);
}
Avoid testing many unrelated things in one test:
@Test
void calculatorWorks() {
Calculator calculator = new Calculator();
assertEquals(5, calculator.add(2, 3));
assertEquals(1, calculator.subtract(3, 2));
assertEquals(6, calculator.multiply(2, 3));
}
That test is harder to diagnose when it fails.
4. Use Meaningful Test Data
Avoid unclear values like this:
Order order = new Order("A", 1, 2, true);
Prefer readable values:
Order order = new Order("BOOK-001", 2, 19.99, true);
Even better, use helper methods when object creation is noisy:
@Test
void calculatesTotalPriceForOrder() {
Order order = orderWithItems(
item("Book", 2, 19.99),
item("Pen", 1, 2.50)
);
BigDecimal total = order.calculateTotal();
assertEquals(new BigDecimal("42.48"), total);
}
Readable test data makes the expected behavior easier to understand.
5. Avoid Logic in Tests
Tests should be simple. Avoid loops, conditionals, and complex calculations unless they are truly necessary.
Avoid:
@Test
void calculatesDiscount() {
double expected = 100 * 0.9;
assertEquals(expected, discountService.applyDiscount(100), 0.001);
}
Better:
@Test
void appliesTenPercentDiscount() {
double result = discountService.applyDiscount(100);
assertEquals(90.0, result, 0.001);
}
The expected value should usually be explicit. If the test calculates the expected value using similar logic to the production code, it may repeat the same bug.
6. Use @BeforeEach for Shared Setup
If several tests need the same object, create it in a @BeforeEach method.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
void addReturnsSumOfTwoNumbers() {
int result = calculator.add(2, 3);
assertEquals(5, result);
}
@Test
void subtractReturnsDifferenceOfTwoNumbers() {
int result = calculator.subtract(10, 4);
assertEquals(6, result);
}
}
Use @BeforeEach for setup that is common and improves readability.
Do not hide important test details in setup. If a value matters for understanding a specific test, keep it inside that test.
7. Keep Assertions Clear
Use the most specific assertion available.
Prefer:
assertEquals(3, items.size());
assertTrue(items.contains("Book"));
assertNotNull(user);
Instead of:
assertTrue(items.size() == 3);
assertTrue(user != null);
Specific assertions usually produce better failure messages.
8. Add Assertion Messages When Helpful
JUnit assertions can include a failure message.
assertEquals(100, account.getBalance(), "Account balance should increase after deposit");
Use messages when they clarify the business expectation. Avoid messages that simply repeat the assertion.
Less useful:
assertEquals(100, balance, "Balance should be 100");
More useful:
assertEquals(100, balance, "Initial promotional credit should be applied to new accounts");
9. Group Related Assertions with assertAll
If you need to verify several independent properties of the same result, use assertAll.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
class UserTest {
@Test
void createsUserProfile() {
User user = new User("Alice", 25, "[email protected]");
assertAll("user profile",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertEquals("[email protected]", user.getEmail())
);
}
}
This makes the test output more useful because JUnit reports all failures in the group instead of stopping at the first one.
Use assertAll when assertions are independent. If one assertion must pass before another is safe, keep it separate.
10. Test Exceptions Clearly
Use assertThrows for expected exceptions.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BankAccountTest {
@Test
void withdrawThrowsExceptionWhenAmountExceedsBalance() {
BankAccount account = new BankAccount(100);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> account.withdraw(150)
);
assertEquals("Insufficient balance", exception.getMessage());
}
}
This clearly shows:
- The expected exception type
- The operation that should fail
- Optional verification of the exception message
11. Avoid Overusing Mocks
Mocks are useful, especially for services, repositories, external APIs, and slow dependencies. But too many mocks can make tests brittle.
Prefer real objects when they are simple and fast.
Good mock use:
@Test
void sendsWelcomeEmailAfterRegistration() {
EmailSender emailSender = mock(EmailSender.class);
UserRepository userRepository = mock(UserRepository.class);
RegistrationService service = new RegistrationService(userRepository, emailSender);
service.register("[email protected]");
verify(emailSender).sendWelcomeEmail("[email protected]");
}
Avoid mocking simple data objects:
User user = mock(User.class);
when(user.getEmail()).thenReturn("[email protected]");
Usually better:
User user = new User("[email protected]");
12. Keep Tests Independent
Each test should be able to run:
- Alone
- In any order
- Repeatedly
- Without depending on previous tests
Avoid shared mutable state between tests.
Risky:
private static List<String> users = new ArrayList<>();
Better:
private List<String> users;
@BeforeEach
void setUp() {
users = new ArrayList<>();
}
13. Prefer One Clear Assertion Target
A test can contain multiple assertions, but they should usually verify the same behavior or outcome.
Good:
@Test
void createsActiveUserWithDefaultRole() {
User user = userService.createUser("[email protected]");
assertAll("created user",
() -> assertEquals("[email protected]", user.getEmail()),
() -> assertEquals("USER", user.getRole()),
() -> assertTrue(user.isActive())
);
}
Less clear:
@Test
void createUserAlsoUpdatesAuditLogAndSendsEmailAndCreatesProfile() {
// too many responsibilities in one test
}
If a test has too many reasons to fail, split it.
14. Use Parameterized Tests for Similar Cases
If you are testing the same behavior with different inputs, use parameterized tests.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"5, 7, 12",
"-1, 1, 0"
})
void addReturnsSum(int left, int right, int expected) {
Calculator calculator = new Calculator();
int result = calculator.add(left, right);
assertEquals(expected, result);
}
}
This avoids repeating nearly identical tests.
15. Keep Test Classes Organized
A common structure is:
class SomeServiceTest {
// fields
// setup methods
// tests
// helper methods
}
Example:
class PriceCalculatorTest {
private PriceCalculator calculator;
@BeforeEach
void setUp() {
calculator = new PriceCalculator();
}
@Test
void appliesDiscountForPremiumCustomer() {
// test
}
@Test
void doesNotApplyDiscountForRegularCustomer() {
// test
}
private Customer premiumCustomer() {
return new Customer("Alice", CustomerType.PREMIUM);
}
}
Keep helper methods at the bottom so the actual test cases remain prominent.
16. Use AssertJ or Hamcrest for Fluent Assertions When Appropriate
JUnit assertions are enough for many tests, but fluent assertion libraries can improve readability for complex objects or collections.
With AssertJ:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProductServiceTest {
@Test
void returnsOnlyActiveProducts() {
List<Product> products = productService.findActiveProducts();
assertThat(products)
.extracting(Product::getName)
.containsExactly("Book", "Pen");
}
}
This can be more readable than several separate collection assertions.
17. Avoid Testing Implementation Details
Test behavior, not private methods or internal steps.
Avoid writing tests that depend on exactly how a method is implemented. Instead, verify observable results.
Prefer:
@Test
void calculatesFinalPriceIncludingTax() {
Money finalPrice = priceService.calculateFinalPrice(new Money("100.00"));
assertEquals(new Money("120.00"), finalPrice);
}
Avoid:
@Test
void callsTaxCalculatorBeforeDiscountCalculator() {
// brittle unless call order is part of the business contract
}
If you refactor the internals without changing behavior, tests should still pass.
18. Make Failures Easy to Diagnose
A failing test should quickly answer:
- What behavior failed?
- What input caused it?
- What was expected?
- What actually happened?
For example:
@Test
void appliesFreeShippingForOrdersOverOneHundredDollars() {
Order order = orderWithTotal(new BigDecimal("120.00"));
ShippingCost cost = shippingService.calculateShipping(order);
assertEquals(ShippingCost.free(), cost);
}
This is much easier to understand than:
@Test
void test1() {
assertEquals(0, service.calc(new BigDecimal("120.00")));
}
Quick Checklist
Use this checklist when reviewing JUnit tests:
- Clear test name
- Arrange-Act-Assert structure
- One behavior per test
- Readable test data
- No unnecessary logic in the test
- Specific assertions
- Independent tests
- Minimal shared state
- Mocks only where useful
- Parameterized tests for repeated cases
- No testing of implementation details
- Helpful failure messages when needed
A clean JUnit test should read almost like documentation for the behavior of your code.
