Skip to content

Vertical E2E in Three Layers: a Green Toast Proves Nothing

Adityo Guni Waluyo

One Playwright flow through three layers: the UI action, the database row via docker exec, then the search API, with permanent cleanup inside the test.

TL;DR

A green UI test once hid a failed database write because the API returned success before the commit finished. Vertical tests fix this: one Playwright flow checks the UI, the actual database row, and the search API. Each test creates its own data and deletes it, so failures point to the exact broken layer.

Playwright filled the admin form, clicked Save, the "Item created" toast appeared, and the report said 1 test passed. Then I opened the database itself, and the row was not there.

No error. Nothing odd in the logs. The toast still shows up.

The wrong guess I made

At the time I felt that was enough: if the UI test is green, the form works, and if the form works, the data must have landed. That reasoning is wrong. Form validation, the API handler, and the DB write are three different failure points, and my test only exercised the first one.

What actually happened back then: the API handler returned success before the transaction commit finished, while the toast was built from the HTTP response alone. The UI never learned whether the row was really written. My test trusted the toast, and the toast trusted an overly optimistic response.

Since then I write tests vertically: one user flow pushed through three layers at once, a UI action, a row in the database, then an API query, cleaned up permanently inside the test itself.

Three layers in one test

The pattern I use now looks roughly like this. Playwright opens the admin form, fills in one item, and then does not stop at the toast:

// tests/admin-create-item.spec.ts
import { test, expect } from '@playwright/test';

test('item baru muncul di DB dan di search API', async ({ page, request }) => {
  // Layer 1: UI action
  await page.goto('/admin/items/new');
  await page.getByLabel('Nama item').fill('Item Uji Vertikal');
  await page.getByRole('button', { name: 'Simpan' }).click();
  await expect(page.getByText('Item tersimpan')).toBeVisible();

  // Layer 2: the database row, checked straight from the container
  const cmd = `mysql -u$DB_USER -p$DB_PASS $DB_NAME -N -e ` +
    `"SELECT COUNT(*) FROM items WHERE name = 'Item Uji Vertikal'"`;
  const out = await execInDbContainer(cmd);
  expect(Number(out.trim())).toBe(1);

  // Layer 3: search API with q=
  const res = await request.get('/api/admin/items?q=Item%20Uji%20Vertikal');
  expect(res.ok()).toBeTruthy();
  const body = await res.json();
  expect(body.items).toHaveLength(1);

  // Permanent cleanup through the same delete endpoint
  await request.delete(`/api/admin/items/${body.items[0].id}`);
});

The execInDbContainer helper basically wraps docker exec, Docker's official command to run a new command inside a running container [6], pointed at the database container. Credentials come from environment variables, never written in the test. The command being executed looks like this:

docker exec app-db mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" \
  -N -e "SELECT COUNT(*) FROM items WHERE name = 'Item Uji Vertikal'"

The expected output is exactly 1. If it prints 0, the row never reached the database even though the toast appeared, and the test fails at the right point. If the command errors because the container is not running, the message reads differently, and that is an environment problem, not an application problem.

Each layer has its own job. The UI proves a user can finish the flow. The database assert proves the data actually persists. The q= query against the admin search API proves the fresh item can be found again on the server side, not just sitting quietly in a table.

The last part must not be skipped: the delete is permanent and happens inside the test, not in an ops script someone runs next week. After DELETE, the row is gone. No leftover data makes the next run depend on the previous one.

Why the pattern makes sense

Playwright ships an official path for validating server-side post-conditions after browser actions: APIRequestContext, available in the same test through the request fixture [1]. No extra HTTP client, no separate API suite. One test file, one flow, three layers.

Isolation is not a matter of taste either. Playwright's official best practices demand two things this pattern delivers directly: every test stands alone with its own data, and test data is controlled against a controlled environment [2]. The data is created by the test through the UI, verified through a different path, then deleted by the test itself. The test touches no external fixtures, and no other test can be disturbed by leftover rows.

The longer version of this pattern: seed data through an API, run the flow, delete through the API too. It is faster, but it loses the point of the exercise, which is that the UI path itself must prove it writes data. I pick the form-driven version because the form is what I need to trust.

What changed after adopting the pattern: bugs that used to surface only in staging now fail in CI within seconds, with the mismatched layer named. Tests got a bit slower since one test does more work. That trade-off is worth it to me, because one honest vertical test beats five tests that only trust a toast.

Sources

1. API testing - Playwright 2. Best practices - Playwright 6. docker exec - Docker Docs

Related articles