Let’s talk about testing Java code. It’s the part of development I believe truly turns good code into reliable software. I’ve seen projects succeed or fail based on the strength of their tests. Today, I want to walk you through ten practical methods that make testing in modern Java not just a chore, but a powerful tool for building confidence in your work. I’ll explain each one as if we’re sitting together, looking at the same screen.
First, let’s consider how we organize our tests. Have you ever opened a test class and been greeted by a hundred methods with names like test1, test2? It’s confusing. JUnit 5 gives us a better way. We can use nested classes to group related tests together, creating a clear, story-like structure.
Think of testing a shopping cart. You have tests for when it’s empty and tests for when it has items. With @Nested, you can group these logically. The test output becomes readable, almost like a specification document. You immediately see the context of each check.
@DisplayName("Shopping Cart Service")
class ShoppingCartServiceTest {
ShoppingCartService service;
@BeforeEach
void setUp() {
service = new ShoppingCartService();
}
@Nested
@DisplayName("When empty")
class WhenEmpty {
@Test
@DisplayName("Should have zero total")
void totalIsZero() {
assertThat(service.calculateTotal()).isZero();
}
}
@Nested
@DisplayName("With items")
class WithItems {
@BeforeEach
void addItems() {
service.addItem(new Item("Book", BigDecimal.valueOf(29.99)));
}
@Test
@DisplayName("Should calculate correct total")
void calculatesTotal() {
assertThat(service.calculateTotal()).isEqualByComparingTo("29.99");
}
}
}
Now, about the statements we use to verify results. The old assertEquals(expected, actual) can be hard to read, especially when the test fails. I prefer AssertJ. Its fluent style makes tests read like plain English sentences. You start with assertThat and chain together clear conditions.
@Test
void assertUserDetails() {
User user = userRepository.findById(123L).orElseThrow();
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("active", true)
.extracting(User::getName, User::getEmail)
.containsExactly("John Doe", "john@example.com");
assertThat(user.getOrders())
.isNotEmpty()
.hasSize(3)
.extracting(Order::getStatus)
.containsOnly(OrderStatus.COMPLETED, OrderStatus.SHIPPED);
}
When a test like this fails, the error message tells you exactly what went wrong. It says, “expected the extracted values to contain exactly [John Doe, john@example.com] but was [Jane Doe, …]“. This saves you precious debugging time. The test becomes both a validator and a clear piece of documentation.
Of course, you can’t test a payment service by actually charging a credit card. This is where mocking comes in. Mockito is my go-to library for creating stand-in objects, or “mocks,” that simulate the behavior of real dependencies. It lets you focus on the logic of the unit you’re testing, not the systems it talks to.
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGatewayClient gatewayClient;
@Mock
private AuditLogger auditLogger;
@InjectMocks
private PaymentService paymentService;
@Test
void processPaymentSuccess() {
PaymentRequest request = new PaymentRequest("order-123", BigDecimal.TEN);
when(gatewayClient.charge(any())).thenReturn(new PaymentResponse("txn_abc", "succeeded"));
PaymentResult result = paymentService.process(request);
assertThat(result.isSuccess()).isTrue();
verify(gatewayClient).charge(request);
verify(auditLogger).logSuccess(any());
}
}
Here, @Mock creates the fake objects. @InjectMocks builds the real PaymentService and plugs the mocks into it. The when(...).thenReturn(...) line defines what the mock should do. Finally, verify checks that the service actually called the dependencies as expected. This isolation is the heart of a good unit test.
But what about tests that need a real database? An in-memory H2 database is fast, but it’s not Postgres or MySQL. Subtle differences in SQL syntax or behavior can cause bugs that only appear in production. This is where Testcontainers shines. It lets you run a real database in a Docker container, just for your test.
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private ProductRepository repository;
@Test
void findBySku() {
Product saved = repository.save(new Product("SKU123", "Test Product"));
Product found = repository.findBySku("SKU123").orElseThrow();
assertThat(found.getId()).isEqualTo(saved.getId());
}
}
The @Container annotation manages the lifecycle of the Postgres container. It starts before all tests in the class and stops after. Your application connects to this real database. You test your actual queries, your JPA mappings, and your transaction management. It gives you a high degree of confidence that your data layer works.
In a system with multiple services, a change in one API can break another without anyone realizing it until deployment. Contract testing solves this. One tool, Pact, works on a simple principle: the team that uses a service (the consumer) defines what they expect from it. This expectation becomes a “contract.” The team that provides the service (the provider) runs tests to ensure they fulfill this contract.
// This test is written by the team that CONSUMES the UserService
@PactTestFor(providerName = "UserService")
public class UserClientContractTest {
@Pact(consumer = "OrderService")
public RequestResponsePact userExistsPact(PactDslWithProvider builder) {
return builder
.given("a user with id 123 exists")
.uponReceiving("a request for user 123")
.path("/users/123")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("name", "John")
.integerType("id", 123))
.toPact();
}
@Test
@PactTestFor(pactMethod = "userExistsPact")
void testUserExists(MockServer mockServer) {
UserClient client = new UserClient(mockServer.getUrl());
User user = client.getUser(123L);
assertThat(user.getName()).isEqualTo("John");
}
}
The consumer test generates a JSON contract file. This file is shared, often via a broker. The provider team then runs a separate verification suite against their live service, using this contract. If they change the API in a way that breaks the contract, their build fails. It’s a powerful way to prevent integration surprises.
Most of our tests use specific examples. We test with a known list [1, 2, 3]. But what about all the lists we didn’t think of? Property-based testing flips this around. You describe a property that should always be true for your code, and the framework generates hundreds of random inputs to check it. Jqwik is excellent for this in Java.
@Property
void reverseTwiceIsOriginal(@ForAll List<Integer> originalList) {
List<Integer> reversed = new ArrayList<>(originalList);
Collections.reverse(reversed);
Collections.reverse(reversed);
assertThat(reversed).isEqualTo(originalList);
}
@Property
void encodedStringCanBeDecoded(@ForAll @AlphaChars @StringLength(min=1, max=100) String original) {
String encoded = Base64.getEncoder().encodeToString(original.getBytes());
String decoded = new String(Base64.getDecoder().decode(encoded));
assertThat(decoded).isEqualTo(original);
}
The @ForAll annotation tells Jqwik to generate random data. It will run this test maybe a hundred times, each with a different random list. If it finds a failing case, it doesn’t just show you a huge list; it “shrinks” the input down to the smallest possible example that still breaks the test. This technique is fantastic for finding edge cases in algorithms, validation logic, or data transformations.
Modern Java is full of asynchronous code—CompletableFuture, reactive streams, message listeners. Testing this can be messy. Using Thread.sleep(1000) is unreliable and slows down your test suite. Awaitility provides a clean way to wait for asynchronous conditions.
@Test
void messageIsProcessedAsynchronously() {
MessageQueue queue = new MessageQueue();
MessageProcessor processor = new MessageProcessor(queue);
processor.start();
queue.send(new Message("test payload"));
await().atMost(5, TimeUnit.SECONDS)
.untilAsserted(() -> {
assertThat(processor.getProcessedCount()).isEqualTo(1);
assertThat(processor.getLastPayload()).isEqualTo("test payload");
});
processor.stop();
}
The await() call sets up a waiting period. The untilAsserted block contains the conditions we expect to eventually become true. Awaitility will poll these conditions repeatedly until they pass or the timeout is reached. It turns a flaky, timing-dependent test into a robust one.
Often, you want to test the same logic with many different inputs. Instead of writing five separate test methods, you can write one parameterized test. JUnit 5 makes this straightforward with annotations like @ValueSource or @CsvSource.
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "level", "civic"})
void testPalindromes(String candidate) {
assertThat(StringUtils.isPalindrome(candidate)).isTrue();
}
@ParameterizedTest
@CsvSource({
"2, 3, 6",
"5, 0, 0",
"10, 10, 100"
})
void testMultiplication(int a, int b, int expected) {
assertThat(Math.multiplyExact(a, b)).isEqualTo(expected);
}
Each line in the @CsvSource becomes a separate invocation of the test. If the third one fails, the report clearly shows the failure was for inputs (10, 10, 100). It keeps your test code concise and covers many scenarios.
When building web applications with Spring, you need to test your controllers. Starting a full server for every test is slow. Spring’s MockMvc lets you test the web layer in isolation. It simulates HTTP requests directly to your controller methods.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUserReturnsOk() throws Exception {
when(userService.findUser(123L))
.thenReturn(Optional.of(new User("John", "john@test.com")));
mockMvc.perform(get("/api/users/123")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@test.com"));
}
}
The @WebMvcTest annotation sets up just the web context, not the whole application. You can mock the service layer with @MockBean. The mockMvc.perform() method builds a request, and the andExpect methods let you assert on the HTTP status, the response body, and even specific JSON paths. It’s a fast, complete way to test your API endpoints.
Finally, let’s touch on performance. While full benchmarking is a separate concern, you can add simple performance guards to your unit tests. These aren’t for measuring nanosecond differences, but for catching a major regression—like an algorithm changing from O(n) to O(n²).
@Test
void hashMapPutPerformance() {
long singleThreadTime = runBenchmark(() -> {
Map<Integer, String> map = new HashMap<>();
for (int i = 0; i < 10_000; i++) {
map.put(i, "value" + i);
}
});
// Baseline established from a previous commit
long baselineTime = 15; // milliseconds
long tolerance = 5; // milliseconds
assertThat(singleThreadTime).isLessThan(baselineTime + tolerance);
}
Here, you establish a baseline performance time for a critical operation. The test ensures new commits don’t exceed that baseline by a significant margin. For more rigorous analysis, you would use the JMH library, but this simple check can be an effective early warning system in your regular build.
These techniques form a toolkit. You might not need all of them in every project, but knowing they exist allows you to choose the right test for the right job. Good tests are more than a bug finder; they are executable documentation, a design aid, and the foundation that lets you change code with confidence. Start with clear structure and assertions, then layer in integration, contracts, and property checks as your system grows. The goal is to build a suite that works for you, catching problems early and giving you the freedom to improve your code continuously.