lecture 12 of 170 completed

API and UI together: designing the boundary

Arrange through the API, assert through the screen. Getting this boundary right is the largest speed win available.

A delete test that creates its contact through the form takes eight seconds and can fail for reasons that have nothing to do with deleting. The same test, arranging through the API, takes under a second and only fails when deletion is broken.

Multiply by a suite and this is usually the single largest speed win available. It is also a design decision that goes wrong in a specific way, so it needs a rule.

The rule: arrange through the API, act and assert through the UI.

test('deleting a contact removes it from the list', async ({ page, api }) => {
  const contact = await api.createContact(uniqueContact());   // arrange: fast

  await page.goto('/contacts');                                // act: real UI
  await page.getByRole('row', { name: contact.name })
    .getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByRole('row', { name: contact.name })).toBeHidden();  // assert: UI
});

Why the boundary sits exactly there. The arrange step is not what you are testing, so speed and reliability are the only things that matter. The act and assert steps are what you are testing, so realism is the only thing that matters.

Building the API client as a fixture:

export class ApiClient {
  constructor(private request: APIRequestContext) {}

  async createContact(data: ContactInput): Promise<Contact> {
    const response = await this.request.post('/api/contacts', { data });
    if (!response.ok()) {
      throw new Error(`Setup failed: POST /api/contacts returned ${response.status()}`);
    }
    return response.json();
  }
}

That error message matters more than it looks. Setup failures must be loud and identifiable. Without the check, a failed setup produces a test failing on a missing row, and an engineer spends twenty minutes debugging deletion when the contact was never created.

Authentication for the API client. The request fixture does not automatically carry the browser's session. Two options: create a request context from the same storageState, or send a token explicitly. Whichever you choose, make it a property of the client rather than something each test handles.

Where this goes wrong.

Verifying through the API instead of the UI. A test that creates by API and checks by API has tested the server twice and the application zero times. The assertion belongs on the screen.

Skipping the UI for the thing under test. If you are testing that the create form works, do not create through the API. That is the one test where the slow path is the point.

Hiding too much in the API layer. When a test's setup is api.setUpEverything(), a reader cannot tell what state the test assumes. Setup should still be readable.

Trusting the API client without testing the flow it replaces. If forty tests arrange by API, no test exercises the create form. Keep one that does.

The second use: verifying side effects the UI does not show.

await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByTestId('toast')).toHaveText('Saved');

const stored = await api.getContact(contact.id);
expect(stored.company).toBe('FoxLabs');

The screen said saved. The API confirms what was actually stored. This catches the class of bug where the UI reports success and the data is wrong, which is exactly the bug users report weeks later and nobody can reproduce.

Remember this: API for arrange, UI for act and assert, loud errors on setup failure, and keep one test that still uses the slow path.

Reference: API testing.