End-to-End Testing for Microservices: RestAssured, Playwright & Testcontainers E2E Strategy in 2026
Microservices E2E testing is expensive, slow, and notoriously flaky — unless you have the right strategy. This guide walks you through a battle-tested E2E testing pyramid for distributed Java systems using RestAssured for API testing, Playwright for browser automation, and Testcontainers for real-service integration, all wired into a lean CI/CD pipeline.
TL;DR — E2E Strategy in One Paragraph
"Follow the testing pyramid: 70% unit, 20% integration/component, 5% contract, 5% E2E. Use RestAssured for API-level E2E. Use Playwright for browser E2E. Use Testcontainers to spin up real dependent services in tests. Avoid slow, flaky E2E tests by pushing tests down the pyramid wherever possible."
Table of Contents
- The E2E Testing Problem in Microservices
- Testing Pyramid Revisited for Distributed Systems
- RestAssured for API E2E Testing: Complete Guide
- Testcontainers: Real Services in E2E Tests
- Playwright for Browser E2E in Java
- Consumer-Driven Contract Testing: Where E2E Starts
- Test Data Management & Environment Isolation
- Handling Async Events & Eventually-Consistent Systems
- CI/CD Integration: Parallelism & Flakiness Management
- E2E Testing Checklist & Anti-Patterns
1. The E2E Testing Problem in Microservices
Traditional E2E testing was designed for monoliths: one database, one process, one deployment unit. Microservices blow that model apart. A typical order-management system might have 12+ services, 3 message brokers, 5 databases, and 2 external payment providers — all of which need to be running, healthy, and in a consistent state for a single E2E test to pass.
Why Microservices E2E Tests Break Down
- Cascading failures: One unhealthy dependent service (e.g., inventory-service is down) breaks all E2E tests that touch the order flow, even if the code under test is unrelated to inventory.
- Network flakiness: Container-to-container calls introduce latency and timeout variability that doesn't exist in unit tests. A 200ms timeout in one service causes spurious failures in CI.
- Test data pollution: Service A creates records that Service B reads. If Service A's test seeding is not perfectly cleaned up, Service B tests fail non-deterministically.
- Environment drift: Staging and production diverge in configuration, data volumes, and infrastructure. E2E tests pass in staging but fail silently in production due to missing environment variables or different TLS certificates.
- State management complexity: Distributed transactions mean you cannot simply roll back a database transaction at the end of a test. Kafka offsets, Redis cache entries, and S3 objects all need explicit cleanup.
- Long feedback cycles: A full E2E suite across 12 services commonly takes 20–45 minutes. Developers stop running it locally and it becomes a CI-only gate, slowing the feedback loop drastically.
The Strategic Response
The answer is not to abandon E2E testing — it is to use it surgically. Reserve E2E tests for critical user journeys (checkout, payment, user registration) and replace everything else with contract tests, component tests, and well-isolated integration tests. The sections that follow show exactly how to implement each layer.
2. Testing Pyramid Revisited for Distributed Systems
The classic testing pyramid (unit → integration → E2E) needs an extra layer when applied to microservices. We insert component tests and contract tests between integration and E2E, dramatically reducing the number of tests that require a fully deployed environment.
Updated Layer Definitions
- Unit tests (70%): Test business logic in complete isolation — no Spring context, no database, no HTTP calls. Pure Java with JUnit 5 and Mockito. Run in milliseconds.
- Integration tests (15%): Test a single component's interaction with real infrastructure (DB, cache, message broker) using Testcontainers. No other services involved.
- Component / service tests (10%): Deploy a single microservice with all its dependencies mocked via WireMock or in-memory stubs. Validate the full HTTP layer of one service end-to-end.
- Contract tests (3%): Verify that service interfaces comply with agreed contracts (Pact). Catches breaking API changes before any E2E test runs.
- E2E tests (2%): Full user journey across deployed services. Reserved for critical flows only. Maximum 10–20 scenarios per system.
3. RestAssured for API E2E Testing: Complete Guide
RestAssured is the de-facto standard for API testing in the Java ecosystem. Its fluent DSL maps cleanly onto the HTTP request/response model and integrates seamlessly with Spring Boot's test infrastructure.
Maven Dependency
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>spring-mock-mvc</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
Spring Boot Test Setup with Random Port
Use @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) to start the full Spring context on a random port, avoiding port conflicts in parallel CI builds. Inject the actual port with @LocalServerPort.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserRegistrationE2ETest {
@LocalServerPort
private int port;
private RequestSpecification spec;
@BeforeEach
void setUp() {
spec = new RequestSpecBuilder()
.setBaseUri("http://localhost")
.setPort(port)
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + getTestJwt())
.build();
}
@Test
void shouldRegisterUserAndReturnCreatedStatus() {
String userId =
given(spec)
.body("""
{
"username": "john_doe",
"email": "john@example.com",
"password": "Secure@123"
}
""")
.when()
.post("/api/v1/users/register")
.then()
.statusCode(201)
.body("username", equalTo("john_doe"))
.body("email", equalTo("john@example.com"))
.body("id", notNullValue())
.extract()
.path("id");
// Verify the user can be retrieved
given(spec)
.pathParam("id", userId)
.when()
.get("/api/v1/users/{id}")
.then()
.statusCode(200)
.body("username", equalTo("john_doe"))
.body("status", equalTo("ACTIVE"));
}
@Test
void shouldReturn409WhenDuplicateEmailRegistered() {
String payload = """
{"username":"duplicate","email":"dup@test.com","password":"Test@123"}
""";
given(spec).body(payload).when().post("/api/v1/users/register").then().statusCode(201);
given(spec).body(payload).when().post("/api/v1/users/register")
.then()
.statusCode(409)
.body("error", containsString("already exists"));
}
}
Key RestAssured Patterns
- RequestSpecification: Centralise base URI, port, auth headers, and content type in a
@BeforeEachsetup method to avoid repetition across tests. - JsonPath assertions:
.body("items[0].name", equalTo("Widget"))uses Hamcrest matchers, supporting nested path expressions. - Response extraction:
.extract().as(UserDto.class)deserialises the response body directly into a Java object for further assertions. - Schema validation:
.body(matchesJsonSchemaInClasspath("schemas/user-response.json"))validates response structure against a JSON schema.
4. Testcontainers: Real Services in E2E Tests
Testcontainers starts real Docker containers (PostgreSQL, Kafka, Redis) inside your JUnit 5 test lifecycle, eliminating the need for external test environments. Tests that used to require a shared staging environment can now run independently on any developer machine or CI runner that has Docker.
Complete Order Service Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderServiceE2ETest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test");
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379);
@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);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@LocalServerPort
private int port;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanDatabase() {
orderRepository.deleteAll();
}
@Test
void shouldCreateOrderAndPublishKafkaEvent() throws Exception {
String orderId =
given()
.baseUri("http://localhost:" + port)
.contentType(ContentType.JSON)
.body("""
{
"customerId": "cust-001",
"items": [{"productId": "prod-42", "quantity": 2}]
}
""")
.when()
.post("/api/v1/orders")
.then()
.statusCode(201)
.body("status", equalTo("PENDING"))
.extract()
.path("orderId");
// Verify persistence
assertThat(orderRepository.findById(orderId)).isPresent();
// Verify Kafka event published (via Awaitility — see Section 8)
await().atMost(10, SECONDS)
.until(() -> orderRepository.findById(orderId)
.map(o -> "CONFIRMED".equals(o.getStatus()))
.orElse(false));
}
}
DockerComposeContainer for Multi-Service Tests
For tests requiring multiple services, DockerComposeContainer reads your existing docker-compose.yml, giving full service orchestration without a separate environment:
@Container
static DockerComposeContainer<?> environment =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService("order-service", 8080,
Wait.forHttp("/actuator/health").forStatusCode(200))
.withExposedService("inventory-service", 8081,
Wait.forHttp("/actuator/health").forStatusCode(200));
5. Playwright for Browser E2E in Java
Microsoft Playwright offers a best-in-class browser automation API that works natively from Java. Unlike Selenium, it uses a single WebSocket connection per browser context, making it significantly faster and more reliable for modern single-page applications.
Maven Dependency
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.44.0</version>
<scope>test</scope>
</dependency>
Login Flow E2E Test
class LoginE2ETest {
static Playwright playwright;
static Browser browser;
BrowserContext context;
Page page;
@BeforeAll
static void launchBrowser() {
playwright = Playwright.create();
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions()
.setHeadless(true) // Always headless in CI
.setSlowMo(0)
);
}
@BeforeEach
void createContextAndPage() {
context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(1280, 720)
.setIgnoreHTTPSErrors(true));
page = context.newPage();
}
@AfterEach
void captureFailureAndClose(TestInfo testInfo) {
if (testInfo.getTags().contains("FAILED")) {
page.screenshot(new Page.ScreenshotOptions()
.setPath(Paths.get("build/test-screenshots/"
+ testInfo.getDisplayName() + ".png")));
}
context.close();
}
@AfterAll
static void closeBrowser() {
browser.close();
playwright.close();
}
@Test
void shouldLoginSuccessfullyAndRedirectToDashboard() {
page.navigate("http://localhost:3000/login");
page.locator("#username").fill("admin@example.com");
page.locator("#password").fill("Admin@123");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign In")).click();
assertThat(page.locator("h1")).containsText("Dashboard");
assertThat(page.locator(".user-menu")).isVisible();
assertThat(page).hasURL("http://localhost:3000/dashboard");
}
@Test
void shouldShowValidationErrorForInvalidCredentials() {
page.navigate("http://localhost:3000/login");
page.fill("#username", "wrong@example.com");
page.fill("#password", "wrongpassword");
page.getByText("Sign In").click();
assertThat(page.locator(".error-message")).isVisible();
assertThat(page.locator(".error-message")).containsText("Invalid credentials");
}
}
Playwright Best Practices for CI
- Always use headless mode in CI: Set
.setHeadless(true)and run Chromium inside Docker with--no-sandbox --disable-setuid-sandboxflags. - Prefer role-based locators:
getByRole(),getByText(), andgetByLabel()are far more resilient to UI refactors than CSS selectors. - One BrowserContext per test: Contexts provide isolated cookies and storage, preventing test pollution without the cost of a new browser per test.
- Auto-wait built-in: Playwright automatically waits for elements to be actionable before clicking or filling. Avoid
Thread.sleep()entirely.
6. Consumer-Driven Contract Testing: Where E2E Starts
Contract testing with Pact pushes the API compatibility verification down from E2E to the component level. The consumer defines what it needs from the provider API and generates a contract file (pact). The provider CI job verifies it can honour that contract, eliminating an entire class of E2E failures caused by API drift.
Consumer Side — Defining the Contract
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "user-service", port = "8080")
class OrderServiceConsumerPactTest {
@Pact(consumer = "order-service")
public RequestResponsePact getUserPact(PactDslWithProvider builder) {
return builder
.given("user cust-001 exists")
.uponReceiving("GET user by ID")
.path("/api/v1/users/cust-001")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("id", "cust-001")
.stringType("email", "john@example.com")
.stringType("status", "ACTIVE"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void shouldFetchUserForOrderCreation(MockServer mockServer) {
UserClient client = new UserClient("http://localhost:" + mockServer.getPort());
UserDto user = client.getUser("cust-001");
assertThat(user.status()).isEqualTo("ACTIVE");
}
}
Provider Verification & can-i-deploy Gate
- Annotate the provider test with
@Provider("user-service")and@PactBroker(url = "${PACT_BROKER_URL}")to pull contracts automatically. - Run
pact-broker can-i-deploy --pacticipant user-service --latestas a CI gate before deploying to staging. A failed can-i-deploy blocks the deployment immediately, without needing to run a single E2E test. - Publish contracts to Pact Broker on every consumer CI build using the Maven
pact:publishgoal.
7. Test Data Management & Environment Isolation
Inconsistent test data is the number-one root cause of flaky E2E tests in microservices environments. A disciplined test data strategy prevents cross-test pollution and makes test failures deterministic and reproducible.
Test Data Builder Pattern
public class TestUserBuilder {
private String username = "test_user_" + UUID.randomUUID();
private String email = UUID.randomUUID() + "@test.example.com";
private String role = "USER";
private String status = "ACTIVE";
public TestUserBuilder withUsername(String username) {
this.username = username; return this;
}
public TestUserBuilder withRole(String role) {
this.role = role; return this;
}
public TestUserBuilder withStatus(String status) {
this.status = status; return this;
}
public UserEntity build() {
return UserEntity.builder()
.username(username).email(email)
.role(role).status(status).build();
}
// Helper: persist directly to repo
public UserEntity buildAndSave(UserRepository repo) {
return repo.save(build());
}
}
// Usage in test
@BeforeEach
void seed() {
activeUser = new TestUserBuilder()
.withRole("ADMIN")
.buildAndSave(userRepository);
}
@AfterEach
void cleanup() {
userRepository.deleteAll();
}
WireMock for External Service Stubs
@SpringBootTest
@AutoConfigureWireMock(port = 0) // Random port, injected via @Value("${wiremock.server.port}")
class PaymentGatewayE2ETest {
@BeforeEach
void stubPaymentGateway() {
stubFor(post(urlEqualTo("/v1/charges"))
.withRequestBody(matchingJsonPath("$.amount"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"chargeId":"ch_test_001","status":"succeeded"}
""")));
}
@AfterEach
void resetStubs() {
WireMock.reset();
}
}
Database Isolation Strategies
- @Transactional rollback: Works for single-service integration tests — Spring rolls back after each test. Does not work across services or with Kafka listeners.
- Database-per-test via Testcontainers: Each test class gets its own PostgreSQL container. Higher overhead but perfect isolation. Combine with container reuse (
withReuse(true)) to amortise startup cost. - Flyway test migrations: Maintain a
src/test/resources/db/migrationfolder with test-specific seed data migrations that run after production schema migrations.
8. Handling Async Events & Eventually-Consistent Systems
Event-driven microservices don't process requests synchronously — an HTTP POST /orders may trigger a Kafka event that an inventory service consumes hundreds of milliseconds later. Testing these flows requires polling-based assertions with bounded wait times.
Awaitility Dependency
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.1</version>
<scope>test</scope>
</dependency>
Awaitility + Kafka Consumer Verification
@SpringBootTest
@Testcontainers
class OrderInventoryIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Autowired
private OrderService orderService;
@Autowired
private InventoryRepository inventoryRepository;
@Test
void shouldDeductInventoryAfterOrderCreated() {
// GIVEN: product with 10 units in stock
inventoryRepository.save(new Inventory("prod-42", 10));
// WHEN: order is placed
orderService.createOrder(new CreateOrderRequest("cust-001", "prod-42", 3));
// THEN: wait up to 30s for async inventory deduction
await()
.atMost(30, SECONDS)
.pollInterval(500, MILLISECONDS)
.untilAsserted(() -> {
Inventory inv = inventoryRepository.findByProductId("prod-42");
assertThat(inv.getAvailableUnits()).isEqualTo(7);
});
}
@Test
void shouldPublishOrderCreatedEventToKafka() throws Exception {
Map<String, Object> consumerProps = KafkaTestUtils.consumerProps(
kafka.getBootstrapServers(), "test-consumer-group", "true");
Consumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(Collections.singletonList("order-events"));
orderService.createOrder(new CreateOrderRequest("cust-002", "prod-10", 1));
await().atMost(20, SECONDS).until(() -> {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
return records.count() > 0 &&
StreamSupport.stream(records.spliterator(), false)
.anyMatch(r -> r.value().contains("ORDER_CREATED"));
});
consumer.close();
}
}
Awaitility Best Practices
- Use
.untilAsserted()rather than.until()for JUnitassertThat()assertions to get descriptive failure messages. - Set sensible
atMost()limits — 30 seconds is usually generous enough for Kafka. Longer waits mask real performance regressions. - Configure
pollInterval()to avoid hammering the database. 500ms polls are a good starting point. - Never replace Awaitility with
Thread.sleep()— fixed sleeps become flaky as CI runners get slower under load.
9. CI/CD Integration: Parallelism & Flakiness Management
A well-structured CI pipeline separates fast unit tests from slower integration and E2E tests, running them in parallel stages and retrying known-flaky tests automatically. Allure Reports provide test history to identify recurring flakiness patterns.
GitHub Actions Workflow with Parallel Test Suites
name: CI Pipeline
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- run: mvn test -pl '*' -Dgroups=unit -q
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-test-results
path: '**/target/surefire-reports/'
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
strategy:
matrix:
service: [order-service, user-service, inventory-service, payment-service]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- run: mvn verify -pl ${{ matrix.service }} -Dgroups=integration
env:
TESTCONTAINERS_RYUK_DISABLED: "true"
- uses: actions/upload-artifact@v4
if: always()
with:
name: integration-results-${{ matrix.service }}
path: ${{ matrix.service }}/target/allure-results/
e2e-tests:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '21', distribution: 'temurin' }
- name: Install Playwright browsers
run: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \
-D exec.args="install chromium" -pl e2e-tests
- run: mvn verify -pl e2e-tests -Dgroups=e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-screenshots
path: e2e-tests/build/test-screenshots/
- uses: actions/upload-artifact@v4
if: always()
with:
name: allure-e2e-results
path: e2e-tests/target/allure-results/
Retry on Failure with Surefire
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<rerunFailingTestsCount>2</rerunFailingTestsCount>
<forkCount>2</forkCount>
<reuseForks>true</reuseForks>
</configuration>
</plugin>
Test Quarantine Strategy
Track test failure rates in Allure History. Any test that fails more than 20% of the time on clean code should be tagged with @Tag("quarantine") and excluded from the main CI gate via -DexcludedGroups=quarantine. A weekly "quarantine review" job runs these tests in a separate workflow. This prevents flaky tests from blocking releases while ensuring they are not silently abandoned.
10. E2E Testing Checklist & Anti-Patterns
E2E Testing Checklist
- ✅ Tests only cover critical user journeys (checkout, registration, payment)
- ✅ Each test has independent test data — no shared state between tests
- ✅
@BeforeEachseeds data and@AfterEachcleans up reliably - ✅ Awaitility is used for all async assertions — no
Thread.sleep() - ✅ Testcontainers used for infrastructure — no shared remote test database
- ✅ Contract tests cover all service boundaries before E2E tests run
- ✅ Playwright screenshots captured on test failure automatically
- ✅ Retry logic (max 2) configured for E2E tests in CI
- ✅ Flaky tests are quarantined and tracked with a fix deadline
- ✅ E2E suite completes in under 15 minutes in CI with parallelism
Top E2E Anti-Patterns
Sustainable E2E Testing in 2026
The most effective microservices teams in 2026 treat E2E tests as a premium, scarce resource. They invest heavily in unit and contract tests at the bottom of the pyramid — where tests are fast, reliable, and cheap to write — and use E2E tests only to validate the critical business flows that cannot be verified any other way. Testcontainers closes the gap between integration and E2E by providing real infrastructure in a controlled, reproducible environment. RestAssured provides the most expressive API for HTTP-level E2E assertions in Java. Playwright handles the browser tier without the fragility of Selenium. Combining all three layers with a disciplined CI pipeline and Awaitility for async flows gives you a test suite you can trust and maintain over the long term.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices · Testing & Quality