End-to-End Testing Serverless Architectures with Playwright

End-to-End Testing Serverless Architectures with Playwright

End-to-end testing a serverless architecture is fundamentally different from testing a traditional server. Your "application" is a graph of functions, queues, databases, and managed services. A user action might trigger five Lambda functions sequentially, write to DynamoDB, publish to SQS, and fire a webhook — all before the frontend receives a response.

Playwright is excellent for testing the user-visible layer. The challenge is what to do about the async back-end flows that happen after the browser gets its response.

This guide covers testing a realistic serverless stack: API Gateway + Lambda + DynamoDB as the synchronous core, plus SQS-triggered functions and webhooks for the async flows.

The Architecture We're Testing

A simplified e-commerce API:

Browser → API Gateway → Lambda (orders) → DynamoDB (orders table)
                                         → SQS (order-events queue)
                                                    ↓
                                         Lambda (inventory) → DynamoDB (inventory table)
                                                    ↓
                                         Lambda (notifications) → SNS → email

When a user places an order:

  1. POST /orders → Lambda creates the order in DynamoDB, publishes to SQS, returns 201
  2. Inventory Lambda processes the SQS message, decrements stock
  3. Notification Lambda sends an order confirmation email

Playwright can test step 1 easily. Steps 2 and 3 require async patterns.

Project Setup

npm install --save-dev @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: process.env.API_BASE_URL || 'https://staging.my-api.com',
    extraHTTPHeaders: {
      'Content-Type': 'application/json',
    },
  },
  projects: [
    {
      name: 'api',
      testMatch: '**/*.api.spec.ts',
      use: { browserName: 'chromium' },
    },
    {
      name: 'async-flows',
      testMatch: '**/*.async.spec.ts',
      timeout: 60_000, // async tests need more time
    },
  ],
});

Testing the Synchronous API Layer

Start with the straightforward synchronous flows. Playwright's request fixture handles API calls without a browser:

// e2e/orders.api.spec.ts
import { test, expect } from '@playwright/test';
import { randomUUID } from 'crypto';

test.describe('Orders API', () => {
  let authToken: string;
  let createdOrderId: string;

  test.beforeAll(async ({ request }) => {
    // Authenticate once
    const response = await request.post('/auth/login', {
      data: { email: 'test@example.com', password: process.env.TEST_USER_PASSWORD },
    });
    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    authToken = body.token;
  });

  test('POST /orders creates order and returns 201', async ({ request }) => {
    const payload = {
      items: [
        { productId: 'prod-001', quantity: 2 },
        { productId: 'prod-002', quantity: 1 },
      ],
      shippingAddress: {
        street: '123 Main St',
        city: 'Portland',
        state: 'OR',
        zip: '97201',
      },
    };

    const response = await request.post('/orders', {
      data: payload,
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.status()).toBe(201);
    
    const body = await response.json();
    expect(body.orderId).toBeDefined();
    expect(body.status).toBe('pending');
    expect(body.items).toHaveLength(2);
    expect(body.createdAt).toBeDefined();
    
    createdOrderId = body.orderId;
  });

  test('GET /orders/:id returns created order', async ({ request }) => {
    const response = await request.get(`/orders/${createdOrderId}`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    expect(body.orderId).toBe(createdOrderId);
    expect(body.status).toBe('pending');
  });

  test('POST /orders without auth returns 401', async ({ request }) => {
    const response = await request.post('/orders', {
      data: { items: [] },
    });
    expect(response.status()).toBe(401);
  });

  test('POST /orders with empty items returns 400', async ({ request }) => {
    const response = await request.post('/orders', {
      data: { items: [] },
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(response.status()).toBe(400);
    const body = await response.json();
    expect(body.error).toContain('items');
  });

  test('GET /orders returns paginated list', async ({ request }) => {
    const response = await request.get('/orders?limit=10&page=1', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    expect(body.items).toBeDefined();
    expect(Array.isArray(body.items)).toBe(true);
    expect(body.pagination).toBeDefined();
    expect(body.pagination.page).toBe(1);
  });
});

Testing Async Event-Driven Flows

The order flow doesn't end at 201. The inventory and notification Lambda functions run asynchronously. Testing them requires polling for expected state changes.

The Poll Helper

Build a reusable polling utility:

// e2e/helpers/wait-for.ts
export async function waitFor<T>(
  condition: () => Promise<T | null | undefined>,
  options: { timeout?: number; interval?: number; message?: string } = {}
): Promise<T> {
  const { timeout = 15_000, interval = 500, message = 'Condition not met' } = options;
  const deadline = Date.now() + timeout;
  let lastResult: unknown;

  while (Date.now() < deadline) {
    const result = await condition();
    if (result !== null && result !== undefined) {
      return result;
    }
    lastResult = result;
    await new Promise(resolve => setTimeout(resolve, interval));
  }

  throw new Error(`${message} (timeout: ${timeout}ms, last result: ${JSON.stringify(lastResult)})`);
}

Testing Inventory Updates

After order creation, the inventory Lambda should decrement stock. Test this by checking the inventory API:

// e2e/inventory-sync.async.spec.ts
import { test, expect } from '@playwright/test';
import { waitFor } from './helpers/wait-for';

test.describe('Inventory sync after order creation', () => {
  let authToken: string;

  test.beforeAll(async ({ request }) => {
    const res = await request.post('/auth/login', {
      data: { email: 'test@example.com', password: process.env.TEST_USER_PASSWORD },
    });
    authToken = (await res.json()).token;
  });

  test('inventory decrements after order is placed', async ({ request }) => {
    // Step 1: Check initial stock
    const stockBefore = await request.get('/inventory/prod-001', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const { quantity: initialQty } = await stockBefore.json();

    // Step 2: Place an order for 2 units
    const orderRes = await request.post('/orders', {
      data: {
        items: [{ productId: 'prod-001', quantity: 2 }],
        shippingAddress: { street: '1 Test St', city: 'Portland', state: 'OR', zip: '97201' },
      },
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(orderRes.status()).toBe(201);

    // Step 3: Poll inventory until it decrements
    const updatedInventory = await waitFor(
      async () => {
        const res = await request.get('/inventory/prod-001', {
          headers: { Authorization: `Bearer ${authToken}` },
        });
        const inv = await res.json();
        return inv.quantity === initialQty - 2 ? inv : null;
      },
      {
        timeout: 15_000,
        interval: 500,
        message: `Expected inventory to drop from ${initialQty} to ${initialQty - 2}`,
      }
    );

    expect(updatedInventory.quantity).toBe(initialQty - 2);
    expect(updatedInventory.lastUpdated).toBeDefined();
  });
});

Testing Webhooks

Webhooks are fire-and-forget from your Lambda's perspective. Testing them requires a webhook receiver that you can query.

Setting Up a Test Webhook Endpoint

Use a temporary server to capture webhook payloads. In CI, deploy a simple Lambda that logs to DynamoDB:

// e2e/helpers/webhook-receiver.ts
import { request as playwrightRequest } from '@playwright/test';

// Your test webhook receiver endpoint — deployed separately
// Stores received webhooks in DynamoDB by event ID
const WEBHOOK_RECEIVER_URL = process.env.WEBHOOK_RECEIVER_URL!;
const WEBHOOK_RECEIVER_API_KEY = process.env.WEBHOOK_RECEIVER_API_KEY!;

export async function getReceivedWebhooks(filter: {
  eventType?: string;
  correlationId?: string;
  since?: number;
}) {
  const apiRequest = await playwrightRequest.newContext();
  const response = await apiRequest.get(`${WEBHOOK_RECEIVER_URL}/events`, {
    headers: { 'x-api-key': WEBHOOK_RECEIVER_API_KEY },
    params: {
      ...(filter.eventType && { eventType: filter.eventType }),
      ...(filter.correlationId && { correlationId: filter.correlationId }),
      ...(filter.since && { since: filter.since.toString() }),
    },
  });
  const body = await response.json();
  return body.events as Array<{ eventType: string; payload: unknown; receivedAt: number }>;
}
// e2e/webhooks.async.spec.ts
import { test, expect } from '@playwright/test';
import { randomUUID } from 'crypto';
import { waitFor } from './helpers/wait-for';
import { getReceivedWebhooks } from './helpers/webhook-receiver';

test.describe('Order webhooks', () => {
  test('order.created webhook fires within 10 seconds', async ({ request }) => {
    const correlationId = randomUUID();
    const beforeTimestamp = Date.now();

    // Authenticate and create order
    const authRes = await request.post('/auth/login', {
      data: { email: 'test@example.com', password: process.env.TEST_USER_PASSWORD },
    });
    const { token } = await authRes.json();

    await request.post('/orders', {
      data: {
        items: [{ productId: 'prod-001', quantity: 1 }],
        shippingAddress: { street: '1 Test St', city: 'Portland', state: 'OR', zip: '97201' },
        metadata: { correlationId }, // passed through to webhook payload
      },
      headers: { Authorization: `Bearer ${token}` },
    });

    // Wait for the webhook to arrive
    const webhook = await waitFor(
      async () => {
        const events = await getReceivedWebhooks({
          eventType: 'order.created',
          correlationId,
          since: beforeTimestamp,
        });
        return events.length > 0 ? events[0] : null;
      },
      {
        timeout: 15_000,
        interval: 1_000,
        message: 'order.created webhook not received',
      }
    );

    expect(webhook.eventType).toBe('order.created');
    const payload = webhook.payload as any;
    expect(payload.correlationId).toBe(correlationId);
    expect(payload.status).toBe('pending');
    expect(webhook.receivedAt - beforeTimestamp).toBeLessThan(10_000);
  });
});

Testing SQS-Triggered Flows

For flows triggered by SQS messages, you can inject test messages directly into the queue and verify the downstream effects:

// e2e/sqs-flows.async.spec.ts
import { test, expect } from '@playwright/test';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { randomUUID } from 'crypto';
import { waitFor } from './helpers/wait-for';

// These tests require AWS SDK access — run against staging/sandbox account
const sqs = new SQSClient({ region: process.env.AWS_REGION || 'us-east-1' });
const ddb = new DynamoDBClient({ region: process.env.AWS_REGION || 'us-east-1' });

const QUEUE_URL = process.env.ORDER_EVENTS_QUEUE_URL!;
const INVENTORY_TABLE = process.env.INVENTORY_TABLE || 'inventory-staging';

test.describe('SQS inventory processor', () => {
  test('processes order-created event and updates inventory', async () => {
    const orderId = randomUUID();
    const productId = 'prod-test-001';

    // Seed initial inventory
    await ddb.send(new PutItemCommand({
      TableName: INVENTORY_TABLE,
      Item: marshall({ productId, quantity: 100, version: 1 }),
    }));

    // Inject the SQS message directly
    await sqs.send(new SendMessageCommand({
      QueueUrl: QUEUE_URL,
      MessageBody: JSON.stringify({
        eventType: 'order.created',
        orderId,
        items: [{ productId, quantity: 3 }],
      }),
      MessageAttributes: {
        eventType: { DataType: 'String', StringValue: 'order.created' },
      },
    }));

    // Wait for the inventory Lambda to process it
    const updatedItem = await waitFor(
      async () => {
        const result = await ddb.send(new GetItemCommand({
          TableName: INVENTORY_TABLE,
          Key: marshall({ productId }),
        }));
        if (!result.Item) return null;
        const item = unmarshall(result.Item);
        return item.version > 1 ? item : null; // version incremented = processed
      },
      {
        timeout: 20_000,
        interval: 500,
        message: 'Inventory was not updated after SQS message',
      }
    );

    expect(updatedItem.quantity).toBe(97); // 100 - 3
    expect(updatedItem.version).toBe(2);
  });

  test('handles duplicate messages idempotently', async () => {
    const orderId = randomUUID();
    const productId = 'prod-test-002';

    await ddb.send(new PutItemCommand({
      TableName: INVENTORY_TABLE,
      Item: marshall({ productId, quantity: 50, version: 1 }),
    }));

    const messageBody = JSON.stringify({
      eventType: 'order.created',
      orderId,
      items: [{ productId, quantity: 2 }],
    });

    // Send the same message twice (simulating SQS at-least-once delivery)
    await Promise.all([
      sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: messageBody })),
      sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: messageBody })),
    ]);

    // Wait for processing
    await waitFor(
      async () => {
        const result = await ddb.send(new GetItemCommand({
          TableName: INVENTORY_TABLE,
          Key: marshall({ productId }),
        }));
        const item = result.Item ? unmarshall(result.Item) : null;
        return item?.version > 1 ? item : null;
      },
      { timeout: 20_000 }
    );

    // Allow time for the second message to also be processed
    await new Promise(resolve => setTimeout(resolve, 3_000));

    const finalResult = await ddb.send(new GetItemCommand({
      TableName: INVENTORY_TABLE,
      Key: marshall({ productId }),
    }));
    const finalItem = unmarshall(finalResult.Item!);

    // Idempotency: quantity should only be decremented once
    expect(finalItem.quantity).toBe(48); // 50 - 2, not 50 - 4
  });
});

Testing Full Browser Flows Against Serverless Backends

When the serverless API backs a frontend, test the full user journey through the browser:

// e2e/checkout.browser.spec.ts
import { test, expect } from '@playwright/test';
import { waitFor } from './helpers/wait-for';

test('complete checkout flow', async ({ page, request }) => {
  // Step 1: Sign in
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'test@example.com');
  await page.fill('[data-testid="password"]', process.env.TEST_USER_PASSWORD!);
  await page.click('[data-testid="login-submit"]');
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();

  // Step 2: Add item to cart
  await page.goto('/products/prod-001');
  await page.click('[data-testid="add-to-cart"]');
  await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');

  // Step 3: Proceed to checkout
  await page.goto('/checkout');
  await page.fill('[data-testid="shipping-street"]', '123 Main St');
  await page.fill('[data-testid="shipping-city"]', 'Portland');
  await page.selectOption('[data-testid="shipping-state"]', 'OR');
  await page.fill('[data-testid="shipping-zip"]', '97201');

  // Step 4: Place order
  await page.click('[data-testid="place-order"]');
  
  // Step 5: Verify confirmation page
  await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
  const orderIdText = await page.locator('[data-testid="order-id"]').textContent();
  const orderId = orderIdText?.trim();
  expect(orderId).toBeTruthy();

  // Step 6: Verify order appears in order history
  await page.goto('/orders');
  await expect(page.locator(`[data-order-id="${orderId}"]`)).toBeVisible();
  await expect(page.locator(`[data-order-id="${orderId}"] [data-testid="order-status"]`))
    .toContainText('pending');

  // Step 7: Wait for async processing and verify status update
  // The inventory Lambda should process the order and trigger a status update
  const orderStatusLocator = page.locator(`[data-order-id="${orderId}"] [data-testid="order-status"]`);
  
  await waitFor(
    async () => {
      await page.reload();
      const status = await orderStatusLocator.textContent();
      return status?.includes('confirmed') ? status : null;
    },
    { timeout: 30_000, interval: 2_000, message: 'Order status never updated to confirmed' }
  );
});

Environment Management for E2E Tests

Running E2E tests against a shared staging environment causes test interference. Use per-run isolated environments:

// e2e/fixtures/isolated-env.ts
import { test as base } from '@playwright/test';
import { randomUUID } from 'crypto';

type IsolatedEnvFixture = {
  runId: string;
  testUserEmail: string;
};

export const test = base.extend<IsolatedEnvFixture>({
  runId: async ({}, use) => {
    await use(randomUUID().slice(0, 8));
  },

  testUserEmail: async ({ runId, request }, use) => {
    // Create a test user scoped to this run
    const email = `test-${runId}@example.com`;
    await request.post('/internal/test-setup/users', {
      data: { email, password: 'test-password-123' },
      headers: { 'x-internal-key': process.env.INTERNAL_API_KEY },
    });

    await use(email);

    // Cleanup
    await request.delete(`/internal/test-setup/users/${email}`, {
      headers: { 'x-internal-key': process.env.INTERNAL_API_KEY },
    });
  },
});

Continuous Testing

The serverless stack you tested today can break tomorrow when a dependency Lambda is updated, an SQS queue fills up, or a DynamoDB table hits a throttle limit. These failures don't happen in local tests — they happen in production.

HelpMeTest runs your Playwright-style tests on a schedule against your live serverless endpoints, testing the full user flows continuously. When the order placement flow breaks at 2am because an upstream Lambda changed its response schema, you get an alert before customers do.

Read more

Start now free