Saturday, October 17, 2026

ng-apimock with playwright

Integrate ng-apimock with Playwright either by running the ng-apimock middleware alongside an Express server (or custom backend) during tests

Testing login and registration flows end to end usually means fighting with flaky backends, seeded databases and error states that are hard to reproduce. In this post I show how to combine ng-apimock (scenario-based API mocking) with Playwright so you can switch the backend between success, validation error, locked account and server failure with one line in a test.

Integrate ng-apimock with Playwright either by running the ng-apimock middleware alongside an Express server (or custom backend) during tests


What is ng-apimock?

ng-apimock is a Node.js framework for scenario-based API mocking. Each mock endpoint has several named responses (scenarios), and you choose which one is active at runtime. It ships with a dev interface for local development and plugins for Cypress, WebdriverIO and Protractor. I could not find an official Playwright plugin, so we drive its REST API from a Playwright fixture instead.

Project structure

mocks/
  login.mock.json
  register.mock.json
tests/
  fixtures.ts
  auth.spec.ts
serve.js
proxy.conf.json
playwright.config.ts
package.json

1. Install

npm i -D @ng-apimock/core @playwright/test express
npx playwright install

2. Define the mocks

The login mock has five scenarios. The one marked default: true is used unless a test picks another.

{
  "name": "login",
  "request": {
    "url": "/api/auth/login",
    "method": "POST"
  },
  "responses": {
    "success": {
      "default": true,
      "status": 200,
      "data": {
        "token": "mock-jwt-token",
        "user": { "id": 1, "name": "Test User", "email": "test@example.com" }
      }
    },
    "invalidCredentials": {
      "status": 401,
      "data": { "message": "Invalid email or password" }
    },
    "accountLocked": {
      "status": 423,
      "data": { "message": "Account locked. Try again later." }
    },
    "serverError": {
      "status": 500,
      "data": { "message": "Internal server error" }
    },
    "slow": {
      "status": 200,
      "delay": 3000,
      "data": {
        "token": "mock-jwt-token",
        "user": { "id": 1, "name": "Test User", "email": "test@example.com" }
      }
    }
  }
}

The register mock covers success, an already-registered email, a server-side validation error and a 500.

{
  "name": "register",
  "request": {
    "url": "/api/auth/register",
    "method": "POST"
  },
  "responses": {
    "success": {
      "default": true,
      "status": 201,
      "data": {
        "id": 2,
        "name": "New User",
        "email": "new@example.com",
        "message": "Registration successful"
      }
    },
    "emailTaken": {
      "status": 409,
      "data": { "message": "Email already registered" }
    },
    "validationError": {
      "status": 422,
      "data": {
        "message": "Validation failed",
        "errors": { "password": "Password must be at least 8 characters" }
      }
    },
    "serverError": {
      "status": 500,
      "data": { "message": "Internal server error" }
    }
  }
}

3. Run the mock server

const apimock = require('@ng-apimock/core');
const express = require('express');

const app = express();
app.set('port', 9999);

apimock.processor.process({ src: 'mocks' });
app.use(apimock.middleware);

app.listen(app.get('port'), () => {
  console.log('@ng-apimock/core running on port', app.get('port'));
});

Point your Angular app at it with a proxy so /api calls reach the mock server:

{
  "/api": {
    "target": "http://localhost:9999",
    "secure": false
  }
}

Start the app with ng serve --proxy-config proxy.conf.json.

4. A Playwright fixture to select scenarios

This fixture resets all mocks to their defaults before and after every test, and exposes a small select() helper.

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

const MOCK_URL = 'http://localhost:9999';

export type MockApi = {
  /** Select a scenario for a mock, e.g. select('login', 'invalidCredentials') */
  select: (identifier: string, scenario: string) => Promise<void>;
  /** Reset every mock back to its default scenario */
  reset: () => Promise<void>;
};

export const test = base.extend<{ mocks: MockApi }>({
  mocks: async ({ request }, use) => {
    const api: MockApi = {
      select: async (identifier, scenario) => {
        const res = await request.put(`${MOCK_URL}/ngapimock/mocks`, {
          data: { identifier, scenario },
        });
        expect(res.ok(), `select ${identifier}/${scenario}`).toBeTruthy();
      },
      reset: async () => {
        const res = await request.put(`${MOCK_URL}/ngapimock/mocks/defaults`);
        expect(res.ok(), 'reset mocks').toBeTruthy();
      },
    };

    await api.reset();
    await use(api);
    await api.reset();
  },
});

export { expect };

5. Write the tests

Each test picks a scenario and asserts on what the user sees. Adjust routes, labels and messages to match your own application.

import { test, expect } from './fixtures';

// NOTE: adjust routes, labels, button names and messages to match your app.

test.describe('Login', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  async function submitLogin(page, email = 'test@example.com', password = 'Password123!') {
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill(password);
    await page.getByRole('button', { name: /log ?in|sign ?in/i }).click();
  }

  test('logs in successfully', async ({ page, mocks }) => {
    await mocks.select('login', 'success');
    await submitLogin(page);
    await expect(page).toHaveURL(/dashboard/);
  });

  test('shows error for invalid credentials', async ({ page, mocks }) => {
    await mocks.select('login', 'invalidCredentials');
    await submitLogin(page, 'test@example.com', 'wrong');
    await expect(page.getByText('Invalid email or password')).toBeVisible();
    await expect(page).toHaveURL(/login/);
  });

  test('shows message when account is locked', async ({ page, mocks }) => {
    await mocks.select('login', 'accountLocked');
    await submitLogin(page);
    await expect(page.getByText(/account locked/i)).toBeVisible();
  });

  test('handles server error', async ({ page, mocks }) => {
    await mocks.select('login', 'serverError');
    await submitLogin(page);
    await expect(page.getByText(/something went wrong|server error/i)).toBeVisible();
  });

  test('shows loading state on slow response', async ({ page, mocks }) => {
    await mocks.select('login', 'slow');
    await submitLogin(page);
    await expect(page.getByRole('button', { name: /log ?in|sign ?in/i })).toBeDisabled();
    await expect(page).toHaveURL(/dashboard/, { timeout: 10_000 });
  });
});

test.describe('Register', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/register');
  });

  async function submitRegister(page, email = 'new@example.com', password = 'Password123!') {
    await page.getByLabel('Name').fill('New User');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password', { exact: true }).fill(password);
    await page.getByRole('button', { name: /register|sign ?up|create account/i }).click();
  }

  test('registers successfully', async ({ page, mocks }) => {
    await mocks.select('register', 'success');
    await submitRegister(page);
    await expect(page.getByText(/registration successful/i)).toBeVisible();
  });

  test('shows error when email is already taken', async ({ page, mocks }) => {
    await mocks.select('register', 'emailTaken');
    await submitRegister(page);
    await expect(page.getByText('Email already registered')).toBeVisible();
  });

  test('shows validation error from the server', async ({ page, mocks }) => {
    await mocks.select('register', 'validationError');
    await submitRegister(page, 'new@example.com', 'short');
    await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
  });

  test('handles server error', async ({ page, mocks }) => {
    await mocks.select('register', 'serverError');
    await submitRegister(page);
    await expect(page.getByText(/something went wrong|server error/i)).toBeVisible();
  });
});

6. Playwright configuration

The webServer option starts both the mock server and the Angular dev server before the tests run.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  // Mock scenarios are global state on the mock server, so run serially.
  // (For parallel runs, isolate tests with the apimockid cookie.)
  workers: 1,
  use: {
    baseURL: 'http://localhost:4200',
  },
  webServer: [
    {
      command: 'node serve.js',
      url: 'http://localhost:9999/ngapimock/health',
      reuseExistingServer: !process.env.CI,
    },
    {
      // Angular dev server with /api proxied to http://localhost:9999
      command: 'ng serve --proxy-config proxy.conf.json',
      url: 'http://localhost:4200',
      reuseExistingServer: !process.env.CI,
      timeout: 120_000,
    },
  ],
});

Run everything with:

npx playwright test

Things to watch out for

  • Run serially. The selected scenario is global state on the mock server, so parallel workers overwrite each other. Use workers: 1, or isolate tests with ng-apimock's apimockid cookie.
  • Check the control endpoints. The fixture calls PUT /ngapimock/mocks and PUT /ngapimock/mocks/defaults. Confirm these against the ng-apimock documentation for your version.
  • Simple cases. If you only need a few mocks inside Playwright, the built-in page.route() needs no server. ng-apimock pays off when you also reuse the same mocks for local development through the dev interface.

Conclusion

With a handful of JSON mock files and a 30-line fixture, every login and registration edge case becomes a one-line scenario switch. The tests are fast, deterministic and independent of a real backend.

No comments:

Post a Comment