Skip to content

feat: server side mocking #34403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 53 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
6f3504d
feat: MockingProxy
Skn0tt Jan 20, 2025
64000f8
Update docs/src/api/class-mockingproxyfactory.md
Skn0tt Jan 20, 2025
b2b9fe8
remove fixme
Skn0tt Jan 20, 2025
d3a78eb
remove map to prevent leak
Skn0tt Jan 20, 2025
ad1b86b
Merge remote-tracking branch 'refs/remotes/origin/mockingproxy' into …
Skn0tt Jan 20, 2025
048f6d9
more
Skn0tt Jan 23, 2025
10646b1
move under own dispatcher
Skn0tt Jan 23, 2025
7467885
some more
Skn0tt Jan 23, 2025
d615fd3
more
Skn0tt Jan 23, 2025
51b6696
add events
Skn0tt Jan 23, 2025
7f9c091
remove .off
Skn0tt Jan 23, 2025
1112663
more
Skn0tt Jan 23, 2025
b236072
fix all tests
Skn0tt Jan 23, 2025
a6bf1f4
one more test
Skn0tt Jan 23, 2025
d6bc3cf
rename _request
Skn0tt Jan 23, 2025
a4d4b5a
revert
Skn0tt Jan 23, 2025
3d89657
remove get-free-port
Skn0tt Jan 23, 2025
bb3defd
add draft for mock-js
Skn0tt Jan 24, 2025
e0e8f2e
move over
Skn0tt Jan 24, 2025
e580a4c
move stuff over to page
Skn0tt Jan 24, 2025
fdad749
uninstall
Skn0tt Jan 24, 2025
e0de516
only emit one event
Skn0tt Jan 24, 2025
7c598a7
move route._context
Skn0tt Jan 24, 2025
4d6a453
revert unneeded
Skn0tt Jan 24, 2025
0ae6af5
Merge branch 'main' into mockingproxy
Skn0tt Jan 27, 2025
7444a76
fix link
Skn0tt Jan 27, 2025
f0e54a4
more lint
Skn0tt Jan 27, 2025
c5aad39
lint
Skn0tt Jan 27, 2025
27f8be2
revert subscription logic
Skn0tt Jan 27, 2025
b2722c6
singular until we have a usecase
Skn0tt Jan 27, 2025
2652338
fix tests, don't emit on page
Skn0tt Jan 27, 2025
2921418
add property tests
Skn0tt Jan 27, 2025
fdfd8be
test securitydetails
Skn0tt Jan 27, 2025
f6685fc
test aborting
Skn0tt Jan 27, 2025
b1021f4
test fetch
Skn0tt Jan 27, 2025
303531a
delete unneeded file
Skn0tt Jan 27, 2025
a0ce23d
update header name
Skn0tt Jan 27, 2025
a1618d2
more lint
Skn0tt Jan 27, 2025
f2cba29
box the fixture to hide it
Skn0tt Jan 27, 2025
f3e0c20
inject page info
Skn0tt Jan 27, 2025
e4e743c
rename a little
Skn0tt Jan 27, 2025
694931a
test page.route
Skn0tt Jan 27, 2025
4887ca6
emit on page, even if we don't know what's the correct one
Skn0tt Jan 27, 2025
edff9fc
call it correlation
Skn0tt Jan 27, 2025
d199c51
use fallback
Skn0tt Jan 27, 2025
662d3b2
more fallback
Skn0tt Jan 27, 2025
4d4ca68
refactor
Skn0tt Jan 27, 2025
e7e5124
refactor
Skn0tt Jan 27, 2025
4a4d930
remove instance prop
Skn0tt Jan 27, 2025
8db16b2
set frame from outside
Skn0tt Jan 27, 2025
75a8ace
add more events
Skn0tt Jan 27, 2025
bcf1240
more events
Skn0tt Jan 27, 2025
e2fe511
test failure
Skn0tt Jan 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions docs/src/mock.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
---

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: This line has a length of 108. Maximum allowed is 100. 1 | const proxyUrl = `http://localhost:8123/`; // 1: Disable Parallelism + hardcode port OR > 2 | const proxyUrl = decodeURIComponent(currentHeaders.get('x-playwright-proxy') ?? ''); // 2: Inject proxy port | ^ Unable to lint: const proxyUrl = `http://localhost:8123/`; // 1: Disable Parallelism + hardcode port OR const proxyUrl = decodeURIComponent(currentHeaders.get('x-playwright-proxy') ?? ''); // 2: Inject proxy port

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Trailing spaces not allowed. > 1 | import { axios } from 'axios'; | ^ 2 | 3 | const api = axios.create({ 4 | baseURL: proxyUrl + 'https://jsonplaceholder.typicode.com', Unable to lint: import { axios } from 'axios'; const api = axios.create({ baseURL: proxyUrl + 'https://jsonplaceholder.typicode.com', });

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Trailing spaces not allowed. > 1 | import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; | ^ 2 | 3 | const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { 4 | opts.path = opts.origin + opts.path; Unable to lint: import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { opts.path = opts.origin + opts.path; opts.origin = `http://localhost:8123`; return dispatch(opts, handler); }) setGlobalDispatcher(proxyingDispatcher); // this will also apply to global fetch

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 1 | // shopping-cart.spec.ts > 2 | import { test, expect } from '@playwright/test' | ^ 3 | 4 | test('checkout applies customer loyalty bonus points', async ({ page }) => { 5 | await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => { Unable to lint: // shopping-cart.spec.ts import { test, expect } from '@playwright/test' test('checkout applies customer loyalty bonus points', async ({ page }) => { await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => { await route.fulfill({ json: { userId: '[email protected]', balance: 100 } }); }) await page.goto('http://localhost:3000/checkout'); await expect(page.getByRole('list')).toMatchAriaSnapshot(` - list "Cart": - listitem: Super Duper Hammer - listitem: Nails - listitem: 16mm Birch Plywood - text: "Price after applying 10$ loyalty discount: 79.99$" - button "Buy now" `); });

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 1 | // instrumentation.ts 2 | > 3 | import { headers } from 'next/headers' | ^ 4 | 5 | export function register() { 6 | if (process.env.NODE_ENV === 'test') { Unable to lint: // instrumentation.ts import { headers } from 'next/headers' export function register() { if (process.env.NODE_ENV === 'test') { const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { const proxy = (await headers()).get('x-playwright-proxy'); if (!proxy) return originalFetch(input, init); const request = new Request(input, init); return originalFetch(decodeURIComponent(proxy) + request.url, request); }; } }

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Missing semicolon. 18 | // ... 19 | return handleBrowserRequest(request, /* ... */); > 20 | }) | ^ 21 | } Unable to lint: import { setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import { AsyncLocalStorage } from 'node:async_hooks'; const headersStore = new AsyncLocalStorage<Headers>(); if (process.env.NODE_ENV === 'test') { const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { const proxy = headersStore.getStore()?.get('x-playwright-proxy'); if (!proxy) return originalFetch(input, init); const request = new Request(input, init); return originalFetch(decodeURIComponent(proxy) + request.url, request); }; } export default function handleRequest(request: Request, /* ... */) { return headersStore.run(request.headers, () => { // ... return handleBrowserRequest(request, /* ... */); }) }

Check warning on line 1 in docs/src/mock.md

View workflow job for this annotation

GitHub Actions / Lint snippets

ts linting error

Error: Parsing error: Argument expression expected. 8 | /* ... */ 9 | provideHttpClient( > 10 | /* ... */, | ^ 11 | withInterceptors([ 12 | (req, next) => { 13 | const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); Unable to lint: // app.config.server.ts import { inject, REQUEST } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; const serverConfig = { providers: [ /* ... */ provideHttpClient( /* ... */, withInterceptors([ (req, next) => { const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); if (proxy) req = req.clone({ url: decodeURIComponent(proxy) + req.url }) return next(req); }, ]) ) ] }; /* ... */
id: mock
title: "Mock APIs"
---
Expand Down Expand Up @@ -554,3 +554,226 @@
```

For more details, see [WebSocketRoute].

## Mock Server
* langs: js

By default, Playwright only has access to the network traffic made by the browser.
To mock and intercept traffic made by the application server, use Playwright's mocking proxy.
How to do this differs for each application. This section explains the moving parts that you can use to embed it in any application. Skip forward to find recipes for Next.js, Remix and Angular.

Playwright's mocking proxy is an HTTP proxy server that's connected to the currently running test. If you send it a request, it will apply the network routes configured via `page.route` and `context.route`, allowing you to reuse your existing browser routes.

For browser network mocking, Playwright always knows what browser context and page a request is coming from. But because there's only a single application server shared by multiple concurrent test runs, it cannot know this for server requests! To resolve this, pick one of these two strategies:

1. [Disable parallelism](./test-parallel.md#disable-parallelism), so that there's only a single test at a time.
2. On the server, read the `x-playwright-proxy` header of incoming requests. When the mocking proxy is configured, Playwright adds this header to all browser requests.

The second strategy can be hard to integrate for some applications, because it requires access to the current request from where you're making your API requests.
If this is possible in your application, this is the recommended approach.
If it isn't, then go with disabling parallelism. It will slow down your test execution, but will make the proxy configuration easier because there will be only a single proxy running, on a port that is hardcoded.

Putting this together, figuring out what proxy to funnel a request should look something like this in your application:

```js
const proxyUrl = `http://localhost:8123/`; // 1: Disable Parallelism + hardcode port OR
const proxyUrl = decodeURIComponent(currentHeaders.get('x-playwright-proxy') ?? ''); // 2: Inject proxy port
```

And this is the Playwright config to go with it:

```ts
// playwright.config.ts
// 1: Disable Parallelism + hardcode port
export default defineConfig({
workers: 1,
use: { mockingProxy: { port: 8123 } }
});

// 2: Inject proxy port
export default defineConfig({
use: { mockingProxy: { port: 'inject' } }
});
```

After figuring out what proxy to send traffic to, you need to direct traffic through it. To do so, prepend the proxy URL to all outgoing HTTP requests:

```js
await fetch(proxyUrl + 'https://api.example.com/users');
```

That's it! Your `context.route` and `page.route` methods can now intercept network traffic from your server:

```ts
// shopping-cart.spec.ts
import { test, expect } from '@playwright/test'

test('checkout applies customer loyalty bonus points', async ({ page }) => {
await page.route('https://users.internal.example.com/loyalty/balance*', (route, request) => {
await route.fulfill({ json: { userId: '[email protected]', balance: 100 } });
})

await page.goto('http://localhost:3000/checkout');

await expect(page.getByRole('list')).toMatchAriaSnapshot(`
- list "Cart":
- listitem: Super Duper Hammer
- listitem: Nails
- listitem: 16mm Birch Plywood
- text: "Price after applying 10$ loyalty discount: 79.99$"
- button "Buy now"
`);
});
```

Prepending the proxy URL manually to all outgoing requests can be cumbersome. If your HTTP client supports it, consider updating your client baseURL ...

```js
import { axios } from 'axios';

const api = axios.create({
baseURL: proxyUrl + 'https://jsonplaceholder.typicode.com',
});
```

... or setting up a global interceptor:

```js
import { axios } from 'axios';

axios.interceptors.request.use(async config => {
config.proxy = { protocol: 'http', host: 'localhost', port: 8123 };
return config;
});
```

```js
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';

const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
opts.path = opts.origin + opts.path;
opts.origin = `http://localhost:8123`;
return dispatch(opts, handler);
})
setGlobalDispatcher(proxyingDispatcher); // this will also apply to global fetch
```

:::note
Note that this style of proxying, where the proxy URL is prended to the request URL, does *not* use [`CONNECT`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT), which is the common way of establishing a proxy connection.
This is because for HTTPS requests, a `CONNECT` proxy does not have access to the proxied traffic. That's great behaviour for a production proxy, but counteracts network interception!
:::


### Recipes
* langs: js

#### Next.js
* langs: js

Monkey-patch `globalThis.fetch` in your `instrumentation.ts` file:

```ts
// instrumentation.ts

import { headers } from 'next/headers'

export function register() {
if (process.env.NODE_ENV === 'test') {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = (await headers()).get('x-playwright-proxy');
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(decodeURIComponent(proxy) + request.url, request);
};
}
}
```

#### Remix
* langs: js


Monkey-patch `globalThis.fetch` in your `entry.server.ts` file, and use `AsyncLocalStorage` to make current request headers available:

```ts
import { setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { AsyncLocalStorage } from 'node:async_hooks';

const headersStore = new AsyncLocalStorage<Headers>();
if (process.env.NODE_ENV === 'test') {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = headersStore.getStore()?.get('x-playwright-proxy');
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(decodeURIComponent(proxy) + request.url, request);
};
}

export default function handleRequest(request: Request, /* ... */) {
return headersStore.run(request.headers, () => {
// ...
return handleBrowserRequest(request, /* ... */);
})
}
```

#### Angular
* langs: js

Configure your `HttpClient` with an [interceptor](https://angular.dev/guide/http/setup#withinterceptors):

```ts
// app.config.server.ts

import { inject, REQUEST } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

const serverConfig = {
providers: [
/* ... */
provideHttpClient(
/* ... */,
withInterceptors([
(req, next) => {
const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy');
if (proxy)
req = req.clone({ url: decodeURIComponent(proxy) + req.url })
return next(req);
},
])
)
]
};

/* ... */
```

```ts
// playwright.config.ts
export default defineConfig({
use: { mockingProxy: { port: 'inject' } }
});
```

#### `.env` file
* langs: js

If your application uses `.env` files to configure API endpoints, you can configure the proxy by prepending them with the proxy URL:

```bash
# .env.test
CMS_BASE_URL=http://localhost:8123/https://cms.example.com/api/
USERS_SERVICE_BASE_URL=http://localhost:8123/https://users.internal.api.example.com/
```

```ts
// playwright.config.ts
export default defineConfig({
workers: 1,
use: { mockingProxy: { port: 8123 } }
});
```
19 changes: 19 additions & 0 deletions docs/src/test-api/class-testoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,3 +676,22 @@ export default defineConfig({
},
});
```

## property: TestOptions.mockingProxy
* since: v1.51
- type: <[Object]>
- `port` <[int]|"inject"> What port to start the mocking proxy on. If set to `"inject"`, Playwright will use a free port and inject the proxy URL into all outgoing requests under the `x-playwright-proxy` header.

**Usage**

```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';

export default defineConfig({
use: {
mockingProxy: {
port: 9956,
},
},
});
```
27 changes: 25 additions & 2 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { Dialog } from './dialog';
import { WebError } from './webError';
import { TargetClosedError, parseError } from './errors';
import { Clock } from './clock';
import type { MockingProxy } from './mockingProxy';

export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
_pages = new Set<Page>();
Expand All @@ -68,6 +69,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
_closeWasCalled = false;
private _closeReason: string | undefined;
private _harRouters: HarRouter[] = [];
_mockingProxy?: MockingProxy;

static from(context: channels.BrowserContextChannel): BrowserContext {
return (context as any)._object;
Expand All @@ -90,7 +92,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._channel.on('bindingCall', ({ binding }) => this._onBinding(BindingCall.from(binding)));
this._channel.on('close', () => this._onClose());
this._channel.on('page', ({ page }) => this._onPage(Page.from(page)));
this._channel.on('route', ({ route }) => this._onRoute(network.Route.from(route)));
this._channel.on('route', params => {
const route = network.Route.from(params.route);
route._context = this.request;
this._onRoute(route);
});
this._channel.on('webSocketRoute', ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
this._channel.on('backgroundPage', ({ page }) => {
const backgroundPage = Page.from(page);
Expand Down Expand Up @@ -198,7 +204,6 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}

async _onRoute(route: network.Route) {
route._context = this;
const page = route.request()._safePage();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
Expand All @@ -223,6 +228,14 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await route._innerContinue(true /* isFallback */).catch(() => {});
}

private _onRouteListener = (route: network.Route) => {
const subject =
route.request()._safePage()
?? this.pages()[0] // Fallback to the first page if no page is associated with the request. This should be the `page` fixture.
?? this;
subject._onRoute(route);
};

async _onWebSocketRoute(webSocketRoute: network.WebSocketRoute) {
const routeHandler = this._webSocketRoutes.find(route => route.matches(webSocketRoute.url()));
if (routeHandler)
Expand All @@ -238,6 +251,14 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await bindingCall.call(func);
}

async _subscribeToMockingProxy(mockingProxy: MockingProxy) {
if (this._mockingProxy)
throw new Error('Multiple mocking proxies are not supported');
this._mockingProxy = mockingProxy;
this._mockingProxy.on(Events.MockingProxy.Route, this._onRouteListener);
await this.route('**', (route: network.Route) => this._mockingProxy!.instrumentBrowserRequest(route));
}

setDefaultNavigationTimeout(timeout: number | undefined) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
this._wrapApiCall(async () => {
Expand Down Expand Up @@ -400,6 +421,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
private async _updateInterceptionPatterns() {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._channel.setNetworkInterceptionPatterns({ patterns });
await this._mockingProxy?.setInterceptionPatterns({ patterns });
}

private async _updateWebSocketInterceptionPatterns() {
Expand Down Expand Up @@ -457,6 +479,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this._disposeHarRouters();
this.tracing._resetStackCounter();
this.emit(Events.BrowserContext.Close, this);
this._mockingProxy?.off(Events.MockingProxy.Route, this._onRouteListener);
}

async [Symbol.asyncDispose]() {
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { findValidator, ValidationError, type ValidatorContext } from '../protoc
import { createInstrumentation } from './clientInstrumentation';
import type { ClientInstrumentation } from './clientInstrumentation';
import { formatCallLog, rewriteErrorMessage, zones } from '../utils';
import { MockingProxy } from './mockingProxy';

class Root extends ChannelOwner<channels.RootChannel> {
constructor(connection: Connection) {
Expand Down Expand Up @@ -279,6 +280,9 @@ export class Connection extends EventEmitter {
if (!this._localUtils)
this._localUtils = result as LocalUtils;
break;
case 'MockingProxy':
result = new MockingProxy(parent, type, guid, initializer);
break;
case 'Page':
result = new Page(parent, type, guid, initializer);
break;
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,8 @@ export const Events = {
Console: 'console',
Window: 'window',
},

MockingProxy: {
Route: 'route',
},
};
Loading
Loading