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 → emailWhen a user places an order:
POST /orders→ Lambda creates the order in DynamoDB, publishes to SQS, returns 201- Inventory Lambda processes the SQS message, decrements stock
- 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.