Yep you guessed it, another article on Unit testing or well Test Driven Development in General. This topic is extensively talked about, blogged and discussed in various places and yet in most software developed today still lacks decent test coverage. So in this article I will outline my take on what a good Unit test case is and the best practices surrounding that. My motivation is for purely selfish reasons, Since I am from time to time asked to create Guidelines for Junior devs on this topic , I need to put all this in a place where I can easily grab them in future.
This article specifically addresses creating Java test cases using JUnit and Mockito for Spring Boot applications. Consider it a practical checklist rather than a comprehensive testing guide. For fundamental unit testing knowledge, I recommend reading “Practical Unit Testing with JUnit and Mockito” by Tomek Kaczanowski, which offers a hands-on approach focused on these frameworks.
This checklist does not address the question of whether or not to follow Test-Driven Development (TDD), as that decision depends on the specific use case. For instance, applying TDD to a legacy system may not always be practical, whereas it is generally more suitable for a new greenfield application. The choice can be complex, resembling a “chicken-and-egg” dilemma—sometimes, you need to write a certain amount of code before you can effectively implement TDD. However, even in legacy systems, TDD can be beneficial, such as when fixing defects, where writing a test case before applying a fix can be valuable. While TDD should be the default approach, there are situations where a more pragmatic approach is necessary.
First lets review on the different type of test cases developers are required to write:
Types of Testing
Unit tests:
The purpose of the unit tests as the name implies is to test a class or method or module in complete isolation. Ideally you should create one Unit Test case suite for a class with various test cases checking for variations in which each public method of a class might get called.
By “In Complete Isolation ” I mean you just mock all dependencies and all the calls that lead to a method outside of the class under test.
Integration Tests
Integration tests expands Unit Testings scope to the entire application. It verifies if multiple components or services work correctly together, testing the interactions between integrated units to ensure they function properly as a combined system.
Contract Tests
Contract testing goal is to validate the compatibility between two services , where one service is dependent on the other. Validates if interactions between services adhere to agreed-upon interfaces (contracts), ensuring services can communicate properly without requiring full integration tests.
In this article however we will focus on Unit Testing , Integration and Contract testing will be covered in subsequent articles.
Anatomy of a Unit Test Case
As Unit test case needs to be repeatable, isolated and most importantly readable. It needs to be produce the exact same results every single time. A good pattern to use is Arrange-Assume-Act-Assert
@Test
public void methodName_scenario_expectedBehavior() {
// ARRANGE: Set up the test environment
// Create mocks, test data, etc.
// ASSUME: Verify preconditions are met (optional) depending the the use case
// Check that the test setup is valid before proceeding
// ACT: Execute the method under test
// ASSERT: Verify the results and interactions
}
Here is an example which tests the order create process when inventory is sufficient.
@Test
public void processOrder_withValidOrderAndSufficientInventory_shouldCompleteSuccessfully() {
// ARRANGE
Order testOrder = new Order("123", "product-456", 2, 50.0);
Customer customer = new Customer("customer-789", "test@example.com");
when(inventoryRepository.checkAvailability("product-456", 2)).thenReturn(true);
when(paymentGateway.processPayment(eq(customer), eq(100.0))).thenReturn(new PaymentResult(true, "payment-id-001"));
when(orderRepository.save(any(Order.class))).thenReturn(testOrder);
doNothing().when(notificationService).sendOrderConfirmation(any(Order.class), eq(customer));
// ASSUME (optional)
assumeTrue(testOrder != null && testOrder.getQuantity() > 0);
assumeTrue(customer != null && customer.getEmail() != null);
// We could also verify our mocks are configured correctly
assumeTrue(inventoryRepository.checkAvailability("product-456", 2));
// ACT
OrderResult result = orderService.processOrder(testOrder, customer);
// ASSERT
assertTrue(result.isSuccessful());
assertEquals("123", result.getOrderId());
verify(inventoryRepository, times(1)).checkAvailability("product-456", 2);
verify(inventoryRepository, times(1)).decrementStock("product-456", 2);
verify(paymentGateway, times(1)).processPayment(customer, 100.0);
verify(orderRepository, times(1)).save(testOrder);
verify(notificationService, times(1)).sendOrderConfirmation(testOrder, customer);
verifyNoMoreInteractions(paymentGateway);
}
So when an assumption fails the test is skipped which marks the test as invalid instead of failing the test. The assume step is optional but may be required for complex tests with multiple dependencies. Assume is also useful when doing TDD where a functionality may not be fully ready and instead of ignoring the test you simply put a precondition to running it.
Key Components of a Unit Test
A good unit test will have the following components.
Clear, descriptive test method name:
- Format:
methodName_scenario_expectedBehavior
- Example:
processOrder_withInsufficientInventory_shouldReturnFailure
Proper mock setup:
- Use
@Mock
for dependencies - Use
@InjectMocks
for the class under test - Initialize mocks with
MockitoAnnotations.openMocks(this)
Clear separation of Arrange-Act-Assert phases:
- ARRANGE: Set up test data and configure mocks
- ACT: Call the method being tested
- ASSERT: Verify results and interactions
Comprehensive verification:
- Verify the return value or state changes
- Verify interactions with dependencies (what methods were called and how many times)
- Verify that no unexpected interactions occurred
Testing both happy paths and edge cases:
- Success scenarios
- Failure scenarios
- Boundary conditions
Precise mock configuration:
- Use exact parameter matchers when possible (
eq()
) - Use flexible matchers when appropriate (
any()
,anyString()
) - Configure mock behavior to return specific values or throw exceptions
Verification of interaction counts:
times(n)
: Verify a method was called exactly n timesnever()
: Verify a method was never calledatLeastOnce()
: Verify a method was called at least onceverifyNoMoreInteractions()
: Ensure no unexpected calls were made
Mock only the types that you Own
- Avoid mocking any third party libraries of external dependencies, create a wrapper interface instead.
- Avoid mocking very simple classes like DTOs which inherently do not contain business logic. Although be cautious here as not all DTOs are simple and some may have some logic as well.
Summary
A unit test aims to verify a single “unit” of code in complete isolation—in Java, this typically means testing individual methods within a class. By mocking or simulating all external dependencies, developers can focus exclusively on testing the unit’s logic. This isolation ensures that test failures directly indicate issues within the unit being tested, rather than problems in dependent components. Effective isolation allows developers to confidently validate their code’s behavior without being affected by failures or changes in other parts of the application.
According to Michael Feathers, author of “Working Effectively with Legacy Code,” a test is not a unit test if:
- It interacts with a database
- It communicates over a network
- It accesses the file system
- It cannot be executed concurrently with other unit tests
- It requires special environment configuration (such as modifying config files) to run
References and Further Reading
- JUnit 5 User Guide
- Mockito Documentation
- AssertJ Documentatio
- Practical Unit Testing with Unit and Mockito by Tomek Kaczanowski
Next Steps
While this checklist tries to cover the important aspects of what constitutes a good Unit tests, it is by no means exhaustive and also does not cover every concept. Here are a few of them which can be explored on further
- Rules: a reusable component that intercepts test method calls and allows you to add behavior before, after, or in place of a test method.
- Custom Matchers: Custom Matchers in JUnit allow us to define more readable and reusable validation logic when asserting conditions in test cases. Hamcrest framework can be used along with Junit to provide this feature.