Let’s talk about testing Java applications. If you’re like me, you started with simple unit tests—checking if a method returns the right number. That’s a good start, but modern applications are complex webs of services, databases, and external APIs. Relying only on those basic tests is like checking you have flour and eggs but never tasting the cake. You need to know the whole system works together. Over time, I’ve learned that robust software needs a layered testing strategy. Here are some methods I use to test the tricky parts.
Sometimes, your application doesn’t break because of your code, but because a service it talks to changes its API. I learned this the hard way after a late-night deployment failure. A team updating a microservice altered a response field name, and our application crashed. We had unit tests, and they passed, because they only tested our code in isolation. We needed to test the agreement between services. This is where contract testing comes in.
The idea is simple. The service calling an API (the consumer) and the service providing it (the provider) make a formal agreement. They document exactly what requests and responses should look like. We then test both sides against this document. If either side breaks the agreement, the tests fail. It stops teams from accidentally breaking each other’s code. One tool for this is Pact.
Here’s how you can set it up from the consumer’s side. You write a test that defines the interaction you expect.
@PactTestFor(providerName = "UserService")
public class UserServiceConsumerTest {
@Pact(consumer = "WebApp")
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("id", "123")
.stringType("name", "Test User"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "userExistsPact")
void testUserExists(MockServer mockServer) {
UserClient client = new UserClient(mockServer.getUrl());
User user = client.getUser("123");
assertThat(user.getName()).isEqualTo("Test User");
}
}
This test does two things. First, it defines a pact: “When I send a GET request to /users/123, I expect a 200 status and a JSON body with an id and a name.” Second, it runs a normal test against a mock server that behaves according to that pact. When the test passes, it generates a JSON contract file.
You then share this file with the team managing the UserService. They run a separate verification suite against their real service. Their test says, “Does my live API actually satisfy all the contracts my consumers have with me?” If they change the response format and break the contract, their build fails. It turns an integration problem into a fast, automated check.
Unit tests often use mocks for databases. But a mock doesn’t know SQL syntax. It doesn’t know about transactions or connection pools. I’ve seen tests pass with mocks only for the real database to reject a query. To test the data layer confidently, you need the real thing. This is where Testcontainers shines.
Testcontainers lets you run actual services like PostgreSQL, Redis, or Kafka inside Docker containers, directly from your JUnit tests. Your tests interact with a real, ephemeral database.
@Testcontainers
public class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb");
@BeforeAll
static void setup() {
System.setProperty("spring.datasource.url", postgres.getJdbcUrl());
System.setProperty("spring.datasource.username", postgres.getUsername());
System.setProperty("spring.datasource.password", postgres.getPassword());
}
@Test
void shouldSaveAndRetrieveUser() {
UserRepository repo = new UserRepository(dataSource);
User user = new User("test", "[email protected]");
User saved = repo.save(user);
assertThat(repo.findById(saved.getId())).isPresent();
}
}
The @Testcontainers and @Container annotations handle the container lifecycle. The container starts before the tests run and stops after. Your application connects to it using the dynamically provided JDBC URL. You’re testing your SQL queries, your JPA mappings, and your transaction logic against the exact database engine you use in production. The tests are slower than unit tests, but the confidence is much higher.
Your application might work perfectly for one user. But what about one hundred users at once? Or a thousand? Performance issues often only show up under load. Finding these problems early is better than discovering them during a sale event. While tools like Apache JMeter are powerful, writing and maintaining XML test plans can be cumbersome. A Java DSL for JMeter lets you define load tests in code.
You can keep these tests in your repository and run them in your continuous integration pipeline. Here’s an example of defining a test plan that simulates 100 users ramping up over 10 seconds and placing orders for a minute.
public class OrderLoadTest {
@Test
void testOrderCreationThroughput() throws IOException {
TestPlan testPlan = TestPlan.builder()
.addThreadGroup(ThreadGroup.builder()
.numThreads(100)
.rampUp(10)
.holdFor(60)
.addSampler(HttpSampler.builder()
.protocol("https")
.domain("api.example.com")
.path("/orders")
.method("POST")
.addHeader("Content-Type", "application/json")
.body("{\\"itemId\\":\\"123\\"}")
.build())
.build())
.build();
testPlan.saveAsJmx("test-plan.jmx");
new JMeter()
.property("jmeter.save.saveservice.output_format", "csv")
.testPlan(testPlan)
.run();
}
}
After running, you analyze the results file. You look for increases in average response time or a rise in error rates. You can set thresholds. For example, if more than 1% of requests fail, or if the 95th percentile response time goes above 500ms, the build can fail. This turns performance regression into a check your team makes with every code change.
Most tests use example-based testing. You think of a few inputs and the expected outputs. assertEquals(add(2, 2), 4). But what about add(-1, 0)? Or add(Integer.MAX_VALUE, 1)? It’s impossible to think of every case. Property-based testing flips this around. You define properties or rules that should always be true for your code, and the framework generates hundreds of random inputs to check them.
A property for a reverse function might be: “If I reverse a list twice, I get the original list back.” Let’s test that with jqwik.
@Property
void reverseOfReverseIsOriginal(@ForAll List<Integer> originalList) {
List<Integer> reversed = new ArrayList<>(originalList);
Collections.reverse(reversed);
Collections.reverse(reversed);
assertThat(reversed).isEqualTo(originalList);
}
@Property
void stringConcatenationLength(@ForAll String s1, @ForAll String s2) {
String concatenated = s1 + s2;
assertThat(concatenated.length()).isEqualTo(s1.length() + s2.length());
}
The @ForAll annotation tells jqwik to generate random lists and strings. It will run each test many times with different data. If a failure is found, it automatically tries to “shrink” the input to find the smallest possible example that breaks your rule. You might discover that your code fails for an empty list or a string with Unicode characters. It finds the corner cases you didn’t consider.
Using a real database like PostgreSQL with Testcontainers is great, but sometimes you need something faster for testing repository logic without the network overhead. An in-memory database like H2 is a common choice. The trick is to configure it to behave as much like your production database as possible.
You can use H2’s compatibility modes and initialize it with a specific schema and data set for each test. This makes tests fast, repeatable, and isolated.
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;INIT=CREATE SCHEMA IF NOT EXISTS app",
"spring.datasource.driver-class-name=org.h2.Driver"
})
@Sql("/test-data/users.sql")
public class UserServiceH2Test {
@Autowired
private UserService userService;
@Test
void shouldFindActiveUsers() {
List<User> activeUsers = userService.findActiveUsers();
assertThat(activeUsers).hasSize(2);
}
}
The MODE=PostgreSQL directive makes H2 try to emulate PostgreSQL’s SQL syntax and behavior. The @Sql annotation runs a script before the test to populate the database with a known state. This is perfect for testing complex query logic or transaction rollbacks without the overhead of managing a container. Just be aware that H2 is not a perfect replica; some advanced SQL functions may differ.
Modern applications are full of asynchronous operations: processing messages from a queue, handling callbacks, or using reactive programming. Testing these is tricky. Your test might check a result before the asynchronous task has finished. The old, bad way is to use Thread.sleep(5000). This makes tests slow and flaky—they might pass on your fast laptop but fail on a busy CI server.
A better approach is to use a library like Awaitility. You tell it what condition you expect and a maximum wait time. It polls that condition until it’s true.
@Test
void messageShouldBeProcessed() {
MessageQueue queue = new MessageQueue();
Consumer consumer = new Consumer(queue);
consumer.start();
queue.publish("test message");
await().atMost(5, SECONDS)
.untilAsserted(() -> {
assertThat(consumer.getProcessedCount()).isEqualTo(1);
assertThat(consumer.getLastMessage()).isEqualTo("test message");
});
}
The test will proceed as soon as both assertions hold true. If the message is processed in 100 milliseconds, the test moves on immediately. If something is broken and the condition is never met, the test fails after 5 seconds with a clear error message. It’s reliable and efficient.
Your application probably calls external services: payment gateways, email providers, weather APIs. You can’t run tests if those services are down, and you certainly can’t test error handling by asking a payment API to simulate a timeout. You need a way to simulate these dependencies. WireMock is a tool that lets you set up a mock HTTP server.
You can define exactly how it should respond to specific requests. You can even simulate slow responses or errors.
@Rule
public WireMockRule wireMockRule = new WireMockRule(8089);
@Test
public void testPaymentGatewayIntegration() {
// Setup the mock response
stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(201)
.withHeader("Content-Type", "application/json")
.withBody("{\\"id\\":\\"pay_123\\",\\"status\\":\\"succeeded\\"}")));
// Your code calls the WireMock server
PaymentClient client = new PaymentClient("http://localhost:8089");
PaymentResult result = client.charge(new BigDecimal("100.00"));
assertThat(result.getId()).isEqualTo("pay_123");
// Verify your code sent the correct request
verify(postRequestedFor(urlEqualTo("/payments")));
}
This test is completely isolated. It runs offline and is fast. You can add more stubs to test how your code handles a 500 error or a malformed response from the external service. WireMock can also record interactions with a real service, which you can then use as stub definitions for offline testing.
How do you know if your tests are good? One useful metric is code coverage: what percentage of your code is executed when tests run. It’s not a perfect measure—100% coverage doesn’t mean bug-free—but low coverage is a clear warning sign. JaCoCo is a library that measures coverage for Java applications.
You typically add it as a plugin to your build tool. In Maven, it looks like this.
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.9</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
When you run mvn verify, JaCoCo instruments your code, runs the tests, and generates a report. You get an HTML page showing which lines and branches were hit. A red line indicates code that was never executed. This helps you find untested edge cases. You can also set minimum coverage thresholds in your CI pipeline to prevent new code from being added without tests.
In a large Spring Boot application, starting the full application context for every test is slow. Often, you just want to test a web controller in isolation, or just the JSON serialization of a class. Spring Boot provides “test slices” that load only a relevant subset of the application.
For example, to test a web controller, you can use @WebMvcTest. This slice sets up the Spring MVC infrastructure, but doesn’t load your services or data repositories. You mock the dependencies.
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
given(userService.getUser("123"))
.willReturn(new User("123", "Test User"));
mvc.perform(get("/users/123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Test User"));
}
}
The test is very fast because it doesn’t start a web server or connect to a database. Other useful slices include @DataJpaTest for testing JPA repositories with an embedded database, and @JsonTest for testing Jackson serialization/deserialization. They help you write focused, efficient tests.
As a project grows, its architecture can drift. You might have a rule that the service layer should not depend on the web layer. But one day, a developer imports a controller class into a service to reuse a constant. The code works, but the clean architecture is compromised. How do you catch this? You can write tests for your architecture with ArchUnit.
ArchUnit lets you write unit tests for your code structure. You define rules, and ArchUnit checks them against your compiled classes.
@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule services_should_not_access_controllers =
noClasses().that().resideInAPackage("..service..")
.should().accessClassesThat()
.resideInAPackage("..controller..");
@ArchTest
static final ArchRule repository_methods_must_be_public =
methods().that().areDeclaredInClassesThat()
.areAnnotatedWith(Repository.class)
.should().bePublic();
}
The first rule enforces a layered architecture. The second ensures all methods in Spring @Repository classes are public (since private methods might not work as expected with Spring’s proxies). If someone violates a rule, the test fails. It turns architectural guidelines into living, enforced code.
Each of these techniques addresses a different kind of risk. Contract tests catch broken integrations. Testcontainers give you confidence in data layer interactions. Property-based tests find hidden bugs. Load tests warn you of performance cliffs. Together, they form a safety net that allows you to change and deploy code with much greater confidence. Start by picking one that solves your most immediate pain point. Add it to your build, get comfortable with it, and then consider the next layer. Testing is not a task you finish; it’s a capability you build and refine over the lifetime of your application.