Testing

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.

Md Sanwar Hossain April 11, 2026 21 min read E2E Testing
End-to-end testing microservices with RestAssured, Playwright and Testcontainers

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

  1. The E2E Testing Problem in Microservices
  2. Testing Pyramid Revisited for Distributed Systems
  3. RestAssured for API E2E Testing: Complete Guide
  4. Testcontainers: Real Services in E2E Tests
  5. Playwright for Browser E2E in Java
  6. Consumer-Driven Contract Testing: Where E2E Starts
  7. Test Data Management & Environment Isolation
  8. Handling Async Events & Eventually-Consistent Systems
  9. CI/CD Integration: Parallelism & Flakiness Management
  10. 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

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

Layer Scope Speed Flakiness Maintenance Cost
Unit Single class/method < 1ms None Low
Integration Service + real DB/MQ 100ms–5s Very low Medium
Component One service, mocked deps 1s–10s Low Medium
Contract API surface only Seconds None Low
E2E Full system, real services Minutes High Very high
E2E testing pyramid for microservices: unit tests at the base through E2E tests at the apex with percentage coverage guidelines
E2E testing pyramid for microservices: unit tests at the base through E2E tests at the apex with percentage coverage guidelines

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

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));
Testcontainers E2E test stack: JUnit 5 test orchestrating PostgreSQL, Kafka, and Redis containers with Spring Boot application under test
Testcontainers E2E test stack: JUnit 5 test orchestrating PostgreSQL, Kafka, and Redis containers with Spring Boot application under test

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

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

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

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

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

Top E2E Anti-Patterns

Anti-Pattern Effect Solution
Shared test state between tests Test pollution, order-dependent failures Use @AfterEach cleanup with TestDataBuilder IDs
Hard-coded Thread.sleep() Flaky on slow CI, unnecessarily slow on fast machines Use Awaitility with bounded timeouts
Testing implementation details Brittle tests that break on refactors Test observable behaviour and API contracts
Full E2E for unit-level logic Extremely slow suite, poor isolation Push tests down the pyramid to unit/integration level
No test isolation Order-dependent test failures Unique test data per test via TestDataBuilder
Ignoring flaky tests False confidence, eroded trust in CI Quarantine policy with fix deadline

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

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · Testing & Quality

All Posts
Last updated: April 11, 2026