Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 018 / 100

Combining API and UI tests: a practical Playwright guide

Faster setup, focused UI checks.

Expona Admin12 min read

Most test suites we build spend far too much time on setup: creating users, seeding databases, navigating through forms. Combining API and UI tests: a practical Playwright guide shows us how to collapse that overhead by creating data where it's fast—the API—and verifying behavior where it matters—the browser. This approach cuts test execution time dramatically while keeping your assertions focused on real user workflows.

Why API and UI testing together makes sense

We've all felt the pain of end-to-end tests that take fifteen minutes to run because they're creating every single piece of state through the UI. Click here, fill that form, wait for the API response, repeat. When we're testing whether a user can edit their profile photo, we don't need to spend two minutes creating the account through the UI—the API does that in milliseconds.

The hybrid approach lets us own our test speed. We use APIs for what they do well: deterministic, fast state creation. We use Playwright for what it does well: verifying that the UI responds correctly to user interactions and displays data as expected. This isn't about testing the API itself; we assume your API tests already cover that. This is about building a test suite that's both reliable and fast enough that developers run it locally before pushing.

Real production workflows already combine both. A user might call an API to bulk-import customers, then open the dashboard in a browser to verify the import succeeded. We're just building tests that mirror that reality.

Create data via API

Before we even open a browser, we set up all the data our test needs through authenticated API calls. This means we need a few prerequisites: an API endpoint that accepts requests (usually a test environment), authentication credentials or tokens, and a way to track which resources we created so we can clean them up afterward.

Let's say we're testing an e-commerce dashboard where an admin needs to see recently created orders. Normally, we'd navigate to an order creation form, fill it out with the browser, submit it, and wait. Instead:

typescript
import { test, expect } from '@playwright/test';

test('Admin sees newly created order in dashboard', async ({ page, request }) => {
  // Create test data via API
  const createOrderResponse = await request.post(
    'https://api.example.com/v1/orders',
    {
      headers: {
        'Authorization': `Bearer ${process.env.TEST_API_TOKEN}`,
      },
      data: {
        customerId: '12345',
        items: [
          { sku: 'WIDGET-A', quantity: 2, priceUsd: 29.99 },
        ],
        shippingAddress: {
          street: '123 Test Ave',
          city: 'Test City',
          state: 'TS',
          zipCode: '12345',
        },
      },
    }
  );

  expect(createOrderResponse.ok()).toBeTruthy();
  const order = await createOrderResponse.json();
  const orderId = order.id;

  // Now test the UI with known data
  await page.goto('https://app.example.com/dashboard');
  await page.fill('input[placeholder="Search orders"]', orderId);
  await page.click('button:has-text("Search")');

  // Verify the UI displays our API-created order
  await expect(page.locator(`text=${orderId}`)).toBeVisible();
  await expect(page.locator('text=WIDGET-A')).toBeVisible();
});

Notice we're using the same `request` context that Playwright provides. This keeps our test isolated and doesn't require spinning up a separate HTTP client library. We authenticate with a test token, send structured data, and capture the response immediately.

The critical detail: we store the resource ID (`orderId` in this case). We'll need it both for our UI verification and for cleanup later. If we're creating multiple resources, keep them in an array or object keyed by type.

In a real e-commerce system, you might also need to create related resources first—a customer, a payment method, a shipping method—before creating the order. Chain those API calls in sequence, capturing each ID:

typescript
const customerResponse = await request.post(
  'https://api.example.com/v1/customers',
  {
    headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
    data: {
      email: `test-${Date.now()}@example.com`,
      name: 'Test Customer',
    },
  }
);
const customer = await customerResponse.json();

const orderResponse = await request.post(
  'https://api.example.com/v1/orders',
  {
    headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
    data: {
      customerId: customer.id,
      items: [...],
    },
  }
);

Keep API setup code as simple as possible. Use helper functions to reduce boilerplate if you're repeating the same patterns across many tests.

Verify behavior through the UI

Once our data exists via API, we navigate to the UI and verify that it's displayed and behaves correctly. This is where Playwright's value shines. We're not testing "can we create data"—we're testing "when data exists, does the UI show it right and does the user interaction work as expected."

Using the same order example, after creating the order via API, we log in and check that the dashboard renders correctly:

typescript
// Login
await page.goto('https://app.example.com/login');
await page.fill('input[type="email"]', 'admin@example.com');
await page.fill('input[type="password"]', process.env.TEST_PASSWORD);
await page.click('button:has-text("Sign in")');

// Wait for navigation to dashboard
await page.waitForURL('**/dashboard');

// Search for the order we just created via API
await page.fill('input[placeholder="Search orders"]', orderId);
await page.click('button:has-text("Search")');

// Verify the UI displays order details correctly
await expect(page.locator(`text=${orderId}`)).toBeVisible();
await expect(page.locator('text=WIDGET-A')).toBeVisible();
await expect(page.locator('text=$29.99')).toBeVisible();

// Test a user interaction: clicking into order details
await page.click(`[data-order-id="${orderId}"]`);
await page.waitForURL(`**/orders/${orderId}`);

// Verify the detail page loaded
await expect(page.locator('h1')).toContainText(`Order ${orderId}`);

The test is now focused. We're not spending time on setup; we're verifying that the UI correctly surfaces the data and that interactions work. If the test fails, we know it's a UI issue or a data rendering issue, not a setup problem.

We can also verify more complex UI behavior that only makes sense once real data exists. For instance, testing that filtering, sorting, and pagination work when there are multiple orders:

typescript
// Create three orders via API
const orderIds = [];
for (let i = 0; i < 3; i++) {
  const response = await request.post(
    'https://api.example.com/v1/orders',
    {
      headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
      data: {
        customerId: '12345',
        items: [{ sku: 'WIDGET-A', quantity: i + 1 }],
      },
    }
  );
  const order = await response.json();
  orderIds.push(order.id);
}

// Navigate to dashboard
await page.goto('https://app.example.com/dashboard');

// Verify all three orders appear in the list
for (const id of orderIds) {
  await expect(page.locator(`text=${id}`)).toBeVisible();
}

// Test sorting
await page.click('[data-sort="newest"]');
await page.waitForTimeout(500); // Wait for sort animation
const firstOrderText = await page.locator('tbody tr').first().textContent();
expect(firstOrderText).toContain(orderIds[2]); // Most recent first

Clean up via API

After the test passes or fails, we need to remove the data we created. This keeps the test environment clean and prevents test pollution—where one test's leftover data interferes with another test. The fast way to clean up is through the API as well.

Playwright's `test.afterEach` hook is perfect for this. It runs after every test, whether it passed or failed:

typescript
import { test, expect } from '@playwright/test';

// Store IDs of created resources
const createdResources = {
  orders: [],
  customers: [],
};

test.afterEach(async ({ request }) => {
  // Delete all created resources
  for (const orderId of createdResources.orders) {
    await request.delete(
      `https://api.example.com/v1/orders/${orderId}`,
      {
        headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
      }
    );
  }

  for (const customerId of createdResources.customers) {
    await request.delete(
      `https://api.example.com/v1/customers/${customerId}`,
      {
        headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
      }
    );
  }

  // Clear for next test
  createdResources.orders = [];
  createdResources.customers = [];
});

test('Admin sees newly created order in dashboard', async ({ page, request }) => {
  // Create customer
  const customerResponse = await request.post(
    'https://api.example.com/v1/customers',
    {
      headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
      data: { email: `test-${Date.now()}@example.com`, name: 'Test Customer' },
    }
  );
  const customer = await customerResponse.json();
  createdResources.customers.push(customer.id);

  // Create order
  const orderResponse = await request.post(
    'https://api.example.com/v1/orders',
    {
      headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
      data: { customerId: customer.id, items: [...] },
    }
  );
  const order = await orderResponse.json();
  createdResources.orders.push(order.id);

  // Test UI...
  await page.goto('https://app.example.com/dashboard');
  // ... rest of test
});

This pattern ensures cleanup happens automatically. Even if a test fails midway, the `afterEach` hook still runs and removes what we created.

In larger suites, you might want to extract this into a helper function or fixture to avoid repeating cleanup logic across many tests:

typescript
async function cleanupOrder(request: APIRequestContext, orderId: string) {
  const response = await request.delete(
    `https://api.example.com/v1/orders/${orderId}`,
    {
      headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` },
    }
  );
  if (!response.ok()) {
    console.warn(`Failed to delete order ${orderId}: ${response.status()}`);
  }
}

test.afterEach(async ({ request }) => {
  for (const orderId of createdResources.orders) {
    await cleanupOrder(request, orderId);
  }
  createdResources.orders = [];
});

Some teams prefer to clean up in `beforeEach` instead, using database truncation or a cleanup script that runs before the entire test suite. The goal is the same: leave no test artifacts behind.

Real-world example: multi-step workflow

Let's combine everything into a realistic scenario: testing a SaaS product where a user needs to create a project, invite a team member, and verify the team member can see the project.

typescript
test('Team member can see shared project', async ({ page, request }) => {
  // Create owner and team member via API
  const ownerResponse = await request.post(
    'https://api.example.com/v1/auth/register',
    {
      data: {
        email: `owner-${Date.now()}@example.com`,
        password: 'TestPassword123!',
        name: 'Project Owner',
      },
    }
  );
  const owner = await ownerResponse.json();
  createdResources.users.push(owner.id);

  const memberResponse = await request.post(
    'https://api.example.com/v1/auth/register',
    {
      data: {
        email: `member-${Date.now()}@example.com`,
        password: 'TestPassword123!',
        name: 'Team Member',
      },
    }
  );
  const member = await memberResponse.json();
  createdResources.users.push(member.id);

  // Owner logs in and creates a project via API
  const loginResponse = await request.post(
    'https://api.example.com/v1/auth/login',
    {
      data: {
        email: owner.email,
        password: 'TestPassword123!',
      },
    }
  );
  const loginData = await loginResponse.json();
  const ownerToken = loginData.token;

  const projectResponse = await request.post(
    'https://api.example.com/v1/projects',
    {
      headers: { 'Authorization': `Bearer ${ownerToken}` },
      data: {
        name: 'Test Project',
        description: 'A project to test sharing',
      },
    }
  );
  const project = await projectResponse.json();
  createdResources.projects.push(project.id);

  // Owner invites member via API
  await request.post(
    `https://api.example.com/v1/projects/${project.id}/invitations`,
    {
      headers: { 'Authorization': `Bearer ${ownerToken}` },
      data: {
        email: member.email,
        role: 'editor',
      },
    }
  );

  // Member logs in and verifies project is visible in UI
  await page.goto('https://app.example.com/login');
  await page.fill('input[type="email"]', member.email);
  await page.fill('input[type="password"]', 'TestPassword123!');
  await page.click('button:has-text("Sign in")');

  await page.waitForURL('**/dashboard');

  // Verify the shared project appears
  await expect(page.locator(`text=Test Project`)).toBeVisible();

  // Test interaction: clicking the project
  await page.click(`text=Test Project`);
  await page.waitForURL(`**/projects/${project.id}`);
  await expect(page.locator('h1')).toContainText('Test Project');
});

This test creates all necessary state via API, then verifies the actual user experience through the browser. The setup is deterministic and fast.

Cuts test time dramatically

Let's talk numbers. A test that creates data through the UI typically involves:

  • Navigating to the creation page: 1-2 seconds
  • Filling out forms with multiple fields: 2-5 seconds
  • Waiting for API responses and page renders: 1-3 seconds
  • Verifying success states: 1 second

That's 5-11 seconds per resource created, before we even test the feature we care about.

Using the API, the same operation takes 200-500 milliseconds. When a test suite creates fifty resources across twenty tests, the difference is substantial: from eight minutes down to a couple of seconds.

In practice, we've seen test suites drop from forty minutes to eight minutes with this approach. That's the difference between developers running tests before committing and developers skipping them because they take too long.

Common mistakes

Not tracking what you created. If you forget to store resource IDs, cleanup becomes impossible. Use a consistent pattern to track everything you create in setup.

Ignoring authentication failures silently. If your API request returns a 401 or 403, the test proceeds with undefined data and fails confusingly later. Always check response status: `expect(response.ok()).toBeTruthy()` or handle errors explicitly.

Testing the API instead of the UI. If your test focuses on verifying API response fields instead of what the user sees in the browser, you're missing the point. API tests belong in your API test suite. This is about verifying UI behavior with known data.

Cleanup code that only runs on success. If cleanup is inside the test instead of `afterEach`, failed tests leave garbage behind. Always use hooks to guarantee cleanup runs.

Hardcoding test credentials. Store authentication tokens, test user emails, and API endpoints in environment variables or a configuration file, not in the test code.

Not handling race conditions in cleanup. If multiple tests run in parallel and try to delete the same resources, you'll get conflicts. Use unique identifiers (timestamps, UUIDs) and don't share resource IDs between tests.

Key takeaways

  • Combine API calls for fast setup with Playwright tests for UI verification. This mirrors how real users and systems interact with your product.
  • Create all test data via authenticated API requests before opening the browser. Capture resource IDs immediately so you can reference them in assertions and cleanup.
  • Verify user-facing behavior in the browser: layouts render correctly, data displays as expected, interactions work as intended. This is where Playwright adds value.
  • Always clean up created resources via API in `test.afterEach()`. This prevents test pollution and keeps your test environment stable.
  • Track every resource you create in a structured object or array. Without it, cleanup and verification become unreliable.
  • Test suites using this pattern run 5–10x faster than those creating all data through the UI, without sacrificing confidence in real user workflows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles