In this capstone project you will combine API and UI automation like a senior SDET: an API client layer, fixtures that create and clean up test users through the API, hybrid tests that verify in the browser, and a CI pipeline with separate API and UI jobs.
๐ What You’ll Learn: the practice API and its quirks, project structure and config with API and UI projects, a test data factory, a typed API client, setup and teardown fixtures, API tests, hybrid API-to-UI tests, a CI pipeline with dependent jobs, an acceptance checklist and stretch goals.
๐ฏ Project Brief
Real-world frameworks rarely test only the UI. Senior automation engineers use the API for fast, reliable setup and cleanup, the UI for what users actually see, and run everything in CI. In this capstone you will build exactly that.
Application under test: https://automationexercise.com, a free practice e-commerce site that offers both a web UI and a documented REST API (see its “API Testing” page).
Time needed: 10 to 15 hours. Complete Project 2 first.
๐ The API You Will Use
| Endpoint | Method | Parameters (form data) | Expected |
|---|---|---|---|
/api/productsList | GET | none | responseCode 200 and a products array |
/api/createAccount | POST | name, email, password, title, birth_date, birth_month, birth_year, firstname, lastname, company, address1, address2, country, zipcode, state, city, mobile_number | responseCode 201, “User created!” |
/api/verifyLogin | POST | email, password | 200 “User exists!” or 404 “User not found!” |
/api/getUserDetailByEmail | GET | email (query) | responseCode 200 with user details |
/api/deleteAccount | DELETE | email, password | responseCode 200, “Account deleted!” |
โ ๏ธ Common mistake: This API returns HTTP 200 for almost every call and puts the real result in a responseCode field in the JSON body. Always assert both the HTTP status and responseCode. This is a common pattern in real legacy APIs, and a classic interview question.
๐ Requirements
- An API client layer with typed methods for each endpoint (API testing, API with POM).
- A
newUserfixture that creates a unique account through the API before a test and deletes it afterwards. - Hybrid tests: create a user via API, then log in and verify through the UI.
- Cross-checks: data shown in the UI matches the API.
- Pure API tests tagged
@apiand UI tests tagged@ui, in separate projects. - CI pipeline with separate API and UI jobs and the HTML report as an artifact (GitHub Actions).
๐ Step 1: Structure
hybrid-framework/
โโโ src/
โ โโโ api/
โ โ โโโ AccountApi.ts
โ โโโ pages/
โ โ โโโ LoginPage.ts
โ โ โโโ components/Header.ts
โ โโโ fixtures/index.ts
โ โโโ data/userFactory.ts
โโโ tests/
โ โโโ api/products.api.spec.ts
โ โโโ api/account.api.spec.ts
โ โโโ ui/login.hybrid.spec.ts
โโโ .github/workflows/ci.yml
โโโ playwright.config.ts
โ๏ธ Step 2: Config with API and UI Projects
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'https://automationexercise.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'api', testMatch: /.*\.api\.spec\.ts/ },
{ name: 'ui', testMatch: /.*\.hybrid\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
],
});
๐ญ Step 3: Unique Test Data with a Factory
Every test needs its own user so tests can run in parallel without clashing (parallel execution):
// src/data/userFactory.ts
export interface NewUser {
name: string; email: string; password: string;
title: string; birth_date: string; birth_month: string; birth_year: string;
firstname: string; lastname: string; company: string;
address1: string; address2: string; country: string;
zipcode: string; state: string; city: string; mobile_number: string;
}
export function buildUser(overrides: Partial<NewUser> = {}): NewUser {
const id = Date.now() + '_' + Math.floor(Math.random() * 10000);
return {
name: 'QA User ' + id,
email: 'qa_' + id + '@example.com',
password: 'Test@' + id,
title: 'Mr', birth_date: '10', birth_month: '5', birth_year: '1995',
firstname: 'QA', lastname: 'Tester', company: 'SQA Tools',
address1: '1 Test Street', address2: 'Floor 2', country: 'India',
zipcode: '411001', state: 'Maharashtra', city: 'Pune', mobile_number: '9999999999',
...overrides,
};
}
This is the Builder/Factory idea from design patterns, in its simplest form.
๐ Step 4: The API Client
// src/api/AccountApi.ts
import { APIRequestContext, APIResponse, expect } from '@playwright/test';
import { NewUser } from '../data/userFactory';
interface ApiResult { responseCode: number; message?: string; [key: string]: unknown }
export class AccountApi {
constructor(private readonly request: APIRequestContext) {}
private async parse(response: APIResponse): Promise<ApiResult> {
expect(response.status(), 'HTTP status').toBe(200);
return response.json();
}
async productsList(): Promise<ApiResult> {
return this.parse(await this.request.get('/api/productsList'));
}
async createAccount(user: NewUser): Promise<ApiResult> {
return this.parse(await this.request.post('/api/createAccount', { form: { ...user } }));
}
async verifyLogin(email: string, password: string): Promise<ApiResult> {
return this.parse(await this.request.post('/api/verifyLogin', { form: { email, password } }));
}
async getUserByEmail(email: string): Promise<ApiResult> {
return this.parse(await this.request.get('/api/getUserDetailByEmail', { params: { email } }));
}
async deleteAccount(email: string, password: string): Promise<ApiResult> {
return this.parse(await this.request.delete('/api/deleteAccount', { form: { email, password } }));
}
}
๐งฉ Step 5: Fixtures: Setup and Cleanup in One Place
The newUser fixture creates the account before the test and deletes it after, even if the test fails (custom fixtures):
// src/fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { AccountApi } from '../api/AccountApi';
import { buildUser, NewUser } from '../data/userFactory';
import { LoginPage } from '../pages/LoginPage';
type Fixtures = { accountApi: AccountApi; newUser: NewUser; loginPage: LoginPage };
export const test = base.extend<Fixtures>({
accountApi: async ({ request }, use) => {
await use(new AccountApi(request));
},
newUser: async ({ accountApi }, use) => {
const user = buildUser();
const created = await accountApi.createAccount(user);
expect(created.responseCode, 'create account').toBe(201);
await use(user); // the test runs here
const deleted = await accountApi.deleteAccount(user.email, user.password);
expect(deleted.responseCode, 'delete account').toBe(200);
},
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
export { expect };
// src/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(private readonly page: Page) {
this.email = page.locator('[data-qa="login-email"]');
this.password = page.locator('[data-qa="login-password"]');
this.submit = page.locator('[data-qa="login-button"]');
}
async open() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
}
๐งช Step 6: API Tests
// tests/api/products.api.spec.ts
import { test, expect } from '../../src/fixtures';
test('products list returns products with required fields', { tag: '@api' }, async ({ accountApi }) => {
const result = await accountApi.productsList();
expect(result.responseCode).toBe(200);
const products = result.products as { id: number; name: string; price: string; brand: string }[];
expect(products.length).toBeGreaterThan(0);
for (const p of products) {
expect(p).toMatchObject({ id: expect.any(Number), name: expect.any(String), price: expect.any(String) });
}
});
// tests/api/account.api.spec.ts
import { test, expect } from '../../src/fixtures';
test('a new user can be verified by login API', { tag: '@api' }, async ({ accountApi, newUser }) => {
const result = await accountApi.verifyLogin(newUser.email, newUser.password);
expect(result.responseCode).toBe(200);
expect(result.message).toBe('User exists!');
});
test('unknown user is not found', { tag: '@api' }, async ({ accountApi }) => {
const result = await accountApi.verifyLogin('nobody_' + Date.now() + '@example.com', 'wrong');
expect(result.responseCode).toBe(404);
});
๐ Step 7: Hybrid Tests (API Setup, UI Verification)
// tests/ui/login.hybrid.spec.ts
import { test, expect } from '../../src/fixtures';
test('user created via API can log in through the UI', { tag: '@ui' }, async ({ page, newUser, loginPage }) => {
await loginPage.open();
await loginPage.login(newUser.email, newUser.password);
await expect(page.getByText('Logged in as ' + newUser.name)).toBeVisible();
});
test('wrong password shows an error', { tag: '@ui' }, async ({ page, newUser, loginPage }) => {
await loginPage.open();
await loginPage.login(newUser.email, 'wrong-password');
await expect(page.getByText('Your email or password is incorrect!')).toBeVisible();
});
Creating the user through the UI would take a long sign-up form and several pages per test. Through the API it takes one request, which makes UI tests faster and far less flaky.
๐ Note: Public practice sites sometimes show cookie-consent or advertising overlays. If one covers the form in your region, close it in a beforeEach or block third-party ad requests with page.route (network mocking).
๐ Step 8: CI with Separate API and UI Jobs
name: Hybrid Framework CI
on: [push, pull_request]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: lts/* }
- run: npm ci
- run: npx playwright test --project=api
ui-tests:
needs: api-tests # run UI only if the API is healthy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: lts/* }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=ui
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
API tests need no browser, so that job is very fast. If the API is down, the UI job does not even start.
โ Acceptance Checklist
- [ ]
AccountApicovers all five endpoints with typed results - [ ] Every test that needs a user gets a unique one from the
newUserfixture - [ ] Accounts are deleted after each test, even on failure
- [ ] At least 6 API tests and 4 hybrid UI tests
- [ ] Both
status()andresponseCodeare asserted - [ ] Suite passes with
--repeat-each=3 --workers=4 - [ ] CI runs API and UI jobs; the HTML report is uploaded
๐ Evaluation Rubric
| Criterion | Weight |
|---|---|
| API layer design and assertions | 25% |
| Fixture design (setup and teardown, isolation) | 25% |
| Hybrid test value (faster, more reliable than UI-only) | 20% |
| Reliability in parallel and CI | 15% |
| Code quality and documentation (README) | 15% |
๐ Stretch Goals
- Cross-check: search a product in the UI and assert its price matches
productsList. - Add
getUserByEmailassertions after an account update withPUT /api/updateAccount. - Validate the products response against a JSON schema.
- Add a nightly scheduled run and a Slack or Teams notification using a custom reporter.
- Run the UI job inside the official Playwright Docker image (Docker).
โ FAQ
๐ค What is a hybrid API and UI test?
A test that uses API calls for setup or verification and the browser for the user-facing part, for example creating a user through the API and then logging in through the UI.
๐ค Why use the API for test setup in Playwright?
API calls are much faster and more stable than clicking through forms, so tests spend their time verifying behaviour instead of preparing data.
๐ค How do I clean up test data in Playwright?
Put creation and deletion in one fixture: create before use() and delete after it. The cleanup runs even when the test fails.
๐ค Why assert both HTTP status and responseCode?
Some APIs always return HTTP 200 and report errors inside the body. Checking only the HTTP status would let real failures pass.
๐ฏ Next Steps
- ๐ Module-wise Practice Assignments
- ๐ผ Playwright Interview Questions for Experienced
- ๐ข Real-World Enterprise Framework
- ๐ญ Playwright Tutorial with TypeScript
๐ฌ Watch the full Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on sqatools.in