Testing
Jakarta EE code runs inside a server: the container provides injection, transactions and REST. So tests come in two kinds. Unit tests check the logic of a class without a server — fast, in fractions of a second. Integration tests start a real server and talk to the application over HTTP — slower, but they check everything together.
Approaches
| Approach | What it checks | Speed |
|---|---|---|
| JUnit without a server | Bean logic: dependencies are set by hand | Milliseconds |
| JUnit + HTTP to a running server | The whole API as a client sees it | Seconds |
Tests in liberty:dev mode | The same, run with Enter right during development | Seconds |
| Arquillian | The test is deployed inside the server, @Inject works in it | Seconds |
| Testcontainers | Server and database in Docker containers | Tens of seconds |
Dependencies
Add JUnit and the Failsafe plugin, which runs integration tests, to pom.xml:
<!-- pom.xml (fragment) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency><!-- pom.xml (fragment, inside build/plugins) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>A unit test
A bean is an ordinary class, so you can create it with new and set its dependencies yourself. Our @Inject fields are package-private, and the test lives in the same package:
// src/test/java/com/example/blog/PostServiceTest.java
package com.example.blog;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class PostServiceTest {
private final List<Post> saved = new ArrayList<>();
private PostService service;
@BeforeEach
void setUp() {
service = new PostService();
service.clock = Clock.fixed(Instant.parse("2026-10-04T10:00:00Z"), ZoneOffset.UTC);
service.repository = new PostRepository() {
@Override
public Post save(Post post) {
saved.add(post);
return post;
}
};
}
@Test
void importsAllPosts() {
int count = service.importPosts(List.of(
new NewPost("First", "Body of the first post"),
new NewPost("Second", "Body of the second post")));
assertEquals(2, count);
assertEquals("2026-10-04", saved.get(0).getCreatedAt().toString());
}
@Test
void rejectsPostWithoutTitle() {
assertThrows(IllegalArgumentException.class,
() -> service.importPosts(List.of(new NewPost("", "No title"))));
}
}Instead of a database there is an anonymous repository subclass that collects posts into a list, and instead of the system clock there is Clock.fixed. This is where the clock producer from the CDI chapter pays off.
An integration test
Failsafe runs classes whose names end in IT. The test sends real HTTP requests through java.net.http.HttpClient:
// src/test/java/com/example/blog/PostResourceIT.java
package com.example.blog;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
class PostResourceIT {
private static final String BASE = "http://localhost:8080/blog/api/posts";
private final HttpClient client = HttpClient.newHttpClient();
@Test
void listsPosts() throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("Hello, Jakarta EE"));
}
@Test
void createsPost() throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"title\":\"From a test\",\"body\":\"Created by PostResourceIT\"}"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(201, response.statusCode());
assertTrue(response.headers().firstValue("Location").isPresent());
}
@Test
void returns404ForMissingPost() throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + "/999")).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(404, response.statusCode());
}
}To start the server before the tests and stop it afterwards, add executions bound to Maven phases to the Liberty plugin:
<!-- pom.xml (fragment, inside liberty-maven-plugin) -->
<executions>
<execution>
<id>start-server</id>
<phase>pre-integration-test</phase>
<goals>
<goal>create</goal>
<goal>install-feature</goal>
<goal>deploy</goal>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>stop-server</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>Running
./mvnw verify[INFO] Running com.example.blog.PostServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- liberty:3.12.3:start (start-server) @ blog ---
[INFO] Running com.example.blog.PostResourceIT
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- liberty:3.12.3:stop (stop-server) @ blog ---
[INFO] BUILD SUCCESS
The output is shortened. Unit tests run in the test phase, then Maven builds the WAR, starts the server, runs the *IT classes and stops the server. In liberty:dev mode the same tests run when you press Enter, without restarting the server.
Common mistake
Running integration tests without a running server, for example with ./mvnw failsafe:integration-test, which skips the phases. All tests fail the same way:
[ERROR] Tests run: 3, Failures: 0, Errors: 3, Skipped: 0 <<< FAILURE! -- in com.example.blog.PostResourceIT
java.net.ConnectException
ConnectException means nothing is listening on port 8080. Run ./mvnw verify or keep liberty:dev open.