JUnit and Mockito Best Practices:
Let’s be honest for a minute:
- If you have ever written unit tests in Java, you have probably said things like 'Why is this test suddenly failing' or 'Who wrote this huge confusing method' And yes, sometimes the answer is you. Let us not talk about that now.
- Here is the truth that testing with JUnit and Mockito does not have to be frustrating or messy. When done right, writing tests can actually be one of the most enjoyable parts of coding.
- Good tests give you the confidence to refactor, the freedom to make changes, and a clear picture of how your code works.
- But that only happens if your tests are written with care. Not just quickly put together.
- This is not just a guide about using JUnit or Mockito. It is about writing tests that are clear, useful, and make your work easier.
Note For Readers: This article is written for experienced ones who have already spent some time writing test cases using JUnit and Mockito. If you are new to unit testing in java, some examples might feel advanced. It is better to start with the basics before exploring these best practices.
So I recommend learning the basics first :)
Let’s begin,
Best Practices for Writing Testcases using Junit and Mockito:
1. One Test Should Check One Thing Only:
// ❌ Not great: this test checks 3 unrelated things in one method
@Test
void processOrder_createsUser_addsPoints_sendsEmail() {
// difficult to debug if one thing fails
}
// ✅ Great: each test checks one behavior
@Test
void shouldSaveUserWhenOrderIsPlaced() {
orderService.placeOrder("USER123", orderRequest);
verify(userRepository).save(any(User.class));
}
@Test
void shouldAddPointsIfEligible() {
when(loyaltyPolicy.isEligible(any())).thenReturn(true);
orderService.placeOrder("USER123", orderRequest);
verify(loyaltyService).addPoints("USER123", 100);
}
Why it’s better: When a test checks multiple things, it becomes hard to figure out what broke when it fails. By testing one behavior at a time, your tests stay short, clean, and easy to fix. It also gives better documentation for future readers.
2. Use Mockito Annotations to Keep Your Setup Clean:
// ❌ Not great: manually creating mocks in every test
UserRepository repo = mock(UserRepository.class);
UserService service = new UserService(repo);
// ✅ Great: cleaner and automatic with annotations
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
UserRepository userRepository;
@InjectMocks
UserService userService;
}
Why it’s better: Manually mocking and injecting dependencies creates clutter. Using @Mock
and @InjectMocks
annotations makes your tests easier to read and avoids repeating setup code. This is especially useful when you have multiple dependencies.
3. Use ArgumentCaptor to Verify What’s Being Saved:
// ❌ Not great: only checking if method was called
verify(invoiceRepository).save(any(Invoice.class));
// ✅ Great: capture the actual object and verify its contents
ArgumentCaptor<Invoice> captor = ArgumentCaptor.forClass(Invoice.class);
verify(invoiceRepository).save(captor.capture());
Invoice saved = captor.getValue();
assertEquals("O-101", saved.getOrderId());
assertEquals(BigDecimal.valueOf(500), saved.getAmount());
Why it’s better: Sometimes you don’t just want to check that a method was called and you want to verify what was passed into it. ArgumentCaptor
gives you full visibility into the object and lets you confirm its data is correct.
4. Use Realistic and Meaningful Test Data:
// ❌ Not great: unrealistic test data with no context
when(userRepository.findById("U123")).thenReturn(new User("U123", "test"));
// ✅ Great: meaningful and aligned with the business logic
User premiumUser = new User("bob", "PREMIUM");
when(userRepository.findById("bob")).thenReturn(Optional.of(premiumUser));
DiscountResult result = discountService.apply("bob");
assertFalse(result.isEligible());
Why it’s better: When your test data is too generic, it’s hard to understand the scenario being tested. Using realistic data helps describe the business rule in this case, “premium users don’t get discounts” and makes your test self-explanatory.
5. Use Fake Repositories When You Don’t Need Mocks:
// ❌ Not great: mocking everything for simple save/retrieve
when(userRepository.findById("david")).thenReturn(Optional.of(new User("david")));
// ✅ Great: use a lightweight in-memory implementation
class InMemoryUserRepository implements UserRepository {
private final Map<String, User> store = new HashMap<>();
public void save(User user) {
store.put(user.getId(), user);
}
public Optional<User> findById(String id) {
return Optional.ofNullable(store.get(id));
}
}
@Test
void shouldSaveUserOnSignup() {
UserRepository repo = new InMemoryUserRepository();
UserService service = new UserService(repo);
service.signup("david", "pass");
assertTrue(repo.findById("david").isPresent());
}
Why it’s better: If your code under test doesn’t need a real database and you’re not just checking method calls, fakes are simpler. They give real behavior (like saving and reading data) and keep your tests fast, readable, and independent of frameworks.
6. Use Parameterized Tests Instead of Duplicates:
// ❌ Not great: three almost identical test methods
@Test void shouldReturnBronzeIfPoints0() {}
@Test void shouldReturnSilverIfPoints1000() {}
@Test void shouldReturnGoldIfPoints5000() {}
// ✅ Great: combine them using a parameterized test
@ParameterizedTest
@CsvSource({
"0, BRONZE",
"1000, SILVER",
"5000, GOLD"
})
void shouldReturnCorrectTier(int points, String expectedTier) {
String actual = loyaltyService.calculateTier(points);
assertEquals(expectedTier, actual);
}
Why it’s better: With parameterized tests, you can test many inputs without repeating yourself. It also ensures consistent logic is tested across different scenarios and you only need to update the test logic in one place.
7. Add Helpful Messages to Your Assertions:
// ❌ Not great: test fails without context
assertEquals(expectedTotal, actualTotal);
// ✅ Great: helpful message shows why it failed
assertEquals(expectedTotal, actualTotal,
"Invoice total mismatch. Expected: " + expectedTotal + ", but got: " + actualTotal);
Why it’s better: When tests fail in CI or locally, a custom message gives you immediate insight without digging into logs or debugging tools. You save time and reduce confusion.
8. Use Fluent Assertions for Cleaner Checks:
// ❌ Not great: multiple separate assertions
assertEquals(BigDecimal.valueOf(299), invoice.getAmount());
assertEquals("USD", invoice.getCurrency());
// ✅ Great: fluent and readable
assertThat(invoice)
.extracting(Invoice::getAmount, Invoice::getCurrency)
.containsExactly(BigDecimal.valueOf(299), "USD");
Why it’s better: Fluent assertions (like those from AssertJ) read like natural language. They make complex verifications easier and keep your test focused on what really matters.
9. Separate Unit Tests from Integration Tests:
// ❌ Not great:
// This test connects to a real database but is treated like a simple unit test.
@Test
void shouldSaveOrderToDatabase() {
orderRepository.save(order);
assertTrue(orderRepository.findById(order.getId()).isPresent());
}
// ✅ Great:
// Keep things clear and separate:
// Unit test – fast and focused
@Test
void shouldCallSaveMethodOnRepository() {
orderService.placeOrder(order);
verify(orderRepository).save(order);
}
// Integration test – runs with real DB, checks everything together
@Test
void shouldActuallySaveToDatabase() {
Order savedOrder = orderRepository.save(order);
assertNotNull(orderRepository.findById(savedOrder.getId()));
}
// Optional: Put them in different folders
// src/test/java/unit/... → unit tests
// src/test/java/integration/... → integration tests
Why it’s better:
- Unit tests are like quick checks, they should run fast and often.
- Integration tests are bigger checks, they take longer, so you run them less.
- Keeping them separate helps your project stay fast and organized.
10. Name Tests Like Descriptive Sentences:
// ❌ Not great: this test name tells you nothing
@Test void test1() {}
// ✅ Great: descriptive and self-explaining
@Test void shouldThrowErrorWhenBalanceIsTooLow() {}
Why it’s better: When your test fails or someone reads it later, they shouldn’t have to guess what it’s about. A good name makes the purpose obvious, even without reading the test body.
11. Avoid Hardcoding the Same Values in Multiple Tests:
// ❌ Not great: duplicated values everywhere
@Test void testDiscountForGoldUser() {
User user = new User("U001", "gold");
// ...
}
@Test void testDiscountForSilverUser() {
User user = new User("U002", "silver");
// ...
}
// ✅ Great: use a reusable helper or constant
private static final User GOLD_USER = new User("U001", "gold");
private static final User SILVER_USER = new User("U002", "silver");
@Test void testDiscountForGoldUser() {
// use GOLD_USER
}
@Test void testDiscountForSilverUser() {
// use SILVER_USER
}
Why it matters: Repeating the same data makes tests harder to maintain. Reusable test constants make your test cases shorter, clearer, and easier to update later.
12. Don’t Test Internal Implementation and Test Behavior:
// ❌ Not great: calling private method using reflection (bad practice)
@Test
void testCalculateTaxAmount() throws Exception {
Method method = BillingService.class.getDeclaredMethod("calculateTax", BigDecimal.class);
method.setAccessible(true);
BigDecimal result = (BigDecimal) method.invoke(new BillingService(), BigDecimal.valueOf(100));
assertEquals(BigDecimal.valueOf(18), result);
}
// ✅ Great: test it through the public method that uses it
@Test
void shouldIncludeTaxInFinalBillAmount() {
Order order = new Order("O101", BigDecimal.valueOf(100)); // base price
BigDecimal finalAmount = billingService.generateFinalBill(order); // internally calculates tax
assertEquals(BigDecimal.valueOf(118), finalAmount); // includes 18% tax
}
Why it matters: You should never test private methods directly using reflection, and not by exposing them just for testing. Private methods are like internal helpers. What really matters is whether the public method that uses them behaves correctly. If it does, your private logic is fine too.
What if your private method is not used by any public method?
Don’t test private methods directly. Focus on testing behavior instead.
If you feel a private method needs its own test, that’s usually a sign it deserves to be:
promoted to package-private and tested safely, or
moved to a separate helper class where it naturally fits.
13. Mock Only What You Own:
// ❌ Not great: mocking Java library classes or trusted frameworks
List<String> mockedList = mock(ArrayList.class);
// ✅ Great: use real classes for simple things, mock only your services
List<String> list = new ArrayList<>();
list.add("item");
@Mock
NotificationService notificationService;
Why it matters: Mocking library classes is unnecessary and can lead to fragile or unrealistic behavior. Mocking things like List, Map, or String is overkill and can cause weird behavior. Only mock the things you or your team wrote (like services, repositories, or APIs).
14. Avoid Using Thread.sleep() in Tests:
// ❌ Not great: makes tests slow and flaky
Thread.sleep(2000); // wait for something
// ✅ Great: use proper synchronization or await patterns
await().atMost(5, SECONDS).until(() -> service.hasProcessed("ID123"));
Why it matters: Using Thread.sleep() makes tests slower and unreliable. If you’re testing async code, use tools like Awaitility to wait for the right condition instead of guessing with delays.
15. Group Related Assertions Together:
// ❌ Not great: scattered assertions
assertEquals("John", user.getName());
assertTrue(user.isActive());
assertEquals("ADMIN", user.getRole());
// ✅ Great: group them together with a meaningful message
assertAll("User details",
() -> assertEquals("John", user.getName()),
() -> assertTrue(user.isActive()),
() -> assertEquals("ADMIN", user.getRole())
);
Why it matters: Grouped assertions give better error messages when multiple checks fail and make it clear that these checks are logically connected.
16. Use Descriptive Test Display Names (Optional but Helpful):
// ❌ Not great: unclear in test reports
@Test
void testApply() {}
// ✅ Great: better display name for test reports
@DisplayName("Should apply discount correctly for GOLD tier")
@Test
void shouldApplyGoldDiscount() {}
Why it matters: If your test reports are ever read by someone else (like a QA team or CI log viewer), good display names make them easier to understand at a glance.
17. Don’t Overuse any()Use Real Values Where Possible:
// ❌ Not great: too generic
verify(userRepository).save(any());
// ✅ Great: pass expected object if you care about it
verify(userRepository).save(expectedUser);
Why it matters: Using any() everywhere might make the test pass, but doesn’t confirm that the right data was used. Use real values when possible to make your test stronger.
18. Use beforeEach() to Avoid Repetition:
// ❌ Not great: repeating setup code in every test
@Test
void testOne() {
UserService service = new UserService(new FakeRepo());
// ...
}
@Test
void testTwo() {
UserService service = new UserService(new FakeRepo());
// ...
}
// ✅ Great: move shared setup into @BeforeEach
UserService service;
@BeforeEach
void setUp() {
service = new UserService(new FakeRepo());
}
Why it matters: Repeating setup code makes tests harder to maintain. @BeforeEach keeps things clean and consistent. It’s easier to update shared logic in one place.
19. Prefer assertThrows Over Try-Catch for Exception Testing:
// ❌ Not great: using try-catch to check for exceptions
try {
service.withdraw(5000);
fail("Expected exception not thrown");
} catch (InsufficientBalanceException ex) {
// ok
}
// ✅ Great: use assertThrows for clarity
assertThrows(InsufficientBalanceException.class, () -> service.withdraw(5000));
Why it matters: assertThrows makes your test shorter, more readable, and less error-prone. It clearly shows that you expect an exception, and eliminates unnecessary code.
20. Use @Nested Classes to Group Related Test Cases:
// ❌ Not great: all tests are mixed in one flat structure
class UserServiceTest {
@Test
void shouldLoginWithCorrectPassword() {}
@Test
void shouldFailLoginWithWrongPassword() {}
@Test
void shouldRegisterNewUserSuccessfully() {}
@Test
void shouldNotAllowDuplicateUsername() {}
}
// ✅ Great: group related behaviors using @Nested classes
class UserServiceTest {
@Nested
class LoginTests {
@Test
void shouldLoginWithCorrectPassword() {}
@Test
void shouldFailLoginWithWrongPassword() {}
}
@Nested
class RegistrationTests {
@Test
void shouldRegisterNewUserSuccessfully() {}
@Test
void shouldNotAllowDuplicateUsername() {}
}
}
Why it matters: Using @Nested helps keep your tests organized by feature. It’s easier to read, understand, and manage when you group related test cases under one roof, just like keeping login-related tests in one room and registration-related tests in another.
Closing Thoughts:
Here’s what great unit tests look like:
- They test one thing at a time.
- They use realistic examples.
- They avoid mocking everything blindly.
- They are fast, clean, and reliable.
- They are written in simple English and make your code easier to trust.
Top comments (0)