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.
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'sapimockidcookie. - Check the control endpoints. The fixture calls
PUT /ngapimock/mocksandPUT /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