Playwright Project: API + UI Hybrid Framework with CI (TypeScript)

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.

Advertisement

๐Ÿ“– 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

EndpointMethodParameters (form data)Expected
/api/productsListGETnoneresponseCode 200 and a products array
/api/createAccountPOSTname, email, password, title, birth_date, birth_month, birth_year, firstname, lastname, company, address1, address2, country, zipcode, state, city, mobile_numberresponseCode 201, “User created!”
/api/verifyLoginPOSTemail, password200 “User exists!” or 404 “User not found!”
/api/getUserDetailByEmailGETemail (query)responseCode 200 with user details
/api/deleteAccountDELETEemail, passwordresponseCode 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 newUser fixture 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 @api and 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

  • [ ] AccountApi covers all five endpoints with typed results
  • [ ] Every test that needs a user gets a unique one from the newUser fixture
  • [ ] Accounts are deleted after each test, even on failure
  • [ ] At least 6 API tests and 4 hybrid UI tests
  • [ ] Both status() and responseCode are asserted
  • [ ] Suite passes with --repeat-each=3 --workers=4
  • [ ] CI runs API and UI jobs; the HTML report is uploaded

๐Ÿ… Evaluation Rubric

CriterionWeight
API layer design and assertions25%
Fixture design (setup and teardown, isolation)25%
Hybrid test value (faster, more reliable than UI-only)20%
Reliability in parallel and CI15%
Code quality and documentation (README)15%

๐Ÿš€ Stretch Goals

  • Cross-check: search a product in the UI and assert its price matches productsList.
  • Add getUserByEmail assertions after an account update with PUT /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

๐ŸŽฌ Watch the full Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on sqatools.in

Leave a Comment