Skip to content

feat(test runner): server side mocking #34520

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 24 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
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
259 changes: 259 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: Strings must use singlequote. 1 | export default defineConfig({ > 2 | use: { mockingProxy: "inject-via-header" } | ^ 3 | }); Unable to lint: export default defineConfig({ use: { mockingProxy: "inject-via-header" } });

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Missing semicolon. 13 | const proxy = inject(REQUEST)?.headers.get('x-playwright-proxy'); 14 | if (proxy) > 15 | req = req.clone({ url: decodeURIComponent(proxy) + req.url }) | ^ 16 | return next(req); 17 | }, 18 | ]), 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); }, ]), ) ] }; /* ... */

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Strings must use singlequote. 1 | // astro.config.mjs 2 | import { defineConfig } from 'astro/config'; > 3 | import type { AstroIntegration } from "astro" | ^ 4 | import { AsyncLocalStorage } from "async_hooks"; 5 | 6 | const playwrightMockingProxy: AstroIntegration = { Unable to lint: // astro.config.mjs import { defineConfig } from 'astro/config'; import type { AstroIntegration } from "astro" import { AsyncLocalStorage } from "async_hooks"; const playwrightMockingProxy: AstroIntegration = { name: 'playwrightMockingProxy', hooks: { 'astro:server:setup': async astro => { if (process.env.NODE_ENV !== 'test') return; const proxyStorage = new AsyncLocalStorage<string>(); const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { const proxy = proxyStorage.getStore(); if (!proxy) return originalFetch(input, init); const request = new Request(input, init); return originalFetch(proxy + request.url, request); }; astro.server.middlewares.use((req, res, next) => { const header = req.headers['x-playwright-proxy'] as string; if (typeof header !== 'string') return next(); proxyStorage.run(decodeURIComponent(header), next); }); }, } }; export default defineConfig({ integrations: [ playwrightMockingProxy ] });

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Strings must use singlequote. 1 | // server/plugins/playwright-mocking-proxy.ts 2 | > 3 | import { getGlobalDispatcher, setGlobalDispatcher } from "undici" | ^ 4 | import { useEvent, getRequestHeader } from '#imports' 5 | 6 | export default defineNitroPlugin(() => { Unable to lint: // server/plugins/playwright-mocking-proxy.ts import { getGlobalDispatcher, setGlobalDispatcher } from "undici" import { useEvent, getRequestHeader } from '#imports' export default defineNitroPlugin(() => { if (process.env.NODE_ENV !== 'test') return; const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => { const isInternal = opts.path.startsWith("/__nuxt") const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy') if (proxy && !isInternal) { const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path); opts.origin = newURL.origin; opts.path = newURL.pathname; } return dispatch(opts, handler) }) setGlobalDispatcher(proxiedDispatcher) });

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

View workflow job for this annotation

GitHub Actions / Lint snippets

js linting error

Error: Missing semicolon. 6 | } 7 | } > 8 | }) | ^ Unable to lint: // nuxt.config.ts export default defineNuxtConfig({ nitro: { experimental: { asyncContext: true, } } })
id: mock
title: "Mock APIs"
---
Expand Down Expand Up @@ -554,3 +554,262 @@
```

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 **experimental** mocking proxy. Note this feature is **experimental** and subject to change.

The mocking proxy is a 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`, reusing your existing browser routes.

To get started, enable the `mockingProxy` option in your Playwright config:

```js
export default defineConfig({
use: { mockingProxy: "inject-via-header" }
});
```

Playwright will now inject the proxy URL into all browser requests under the `x-playwright-proxy` header.
On your server, read the URL in this header and prepend it to all outgoing traffic you want to intercept:

```js
const headers = getCurrentRequestHeaders(); // this looks different for each application
const proxyURL = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
await fetch(proxyURL + 'https://api.example.com/users');
```

Prepending the URL will direct the request through the proxy. You can now intercept it with [`method: BrowserContext.route`] and [`method: Page.route`], just like browser requests:
```js
// 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"
`);
});
```

Now, prepending the proxy URL manually can be cumbersome. If your HTTP client supports it, consider setting up a global interceptor:

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

axios.interceptors.request.use(async config => {
const headers = getCurrentRequestHeaders(); // this line looks different for each application
const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
config.url = new URL(proxy + config.url, config.baseURL).toString();
return config;
});
```

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

const proxyingDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
const headers = getCurrentRequestHeaders(); // this line looks different for each application
const proxy = decodeURIComponent(headers.get('x-playwright-proxy') ?? '');
const newURL = new URL(proxy + opts.origin + opts.path);
opts.origin = newURL.origin;
opts.path = newURL.pathname;
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 prepended 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!
:::

:::note
Known Limitations:

1. The mocking proxy is experimental and subject to change.
2. The injected `x-playwright-proxy` header affects CORS and might turn [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) into requests that require a preflight.
3. Requests on the server that were not made in response to a browser request, like those triggered by CRON job, won't be routed because they don't have access to the `x-playwright-proxy` header.
:::


### Recipes
* langs: js

#### Next.js
* langs: js

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

```js
// 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:

```js
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):

```js
// 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);
},
]),
)
]
};

/* ... */
```

#### Astro
* langs: js

Set up a server-side fetch override in an Astro integration:

```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import type { AstroIntegration } from "astro"
import { AsyncLocalStorage } from "async_hooks";

const playwrightMockingProxy: AstroIntegration = {
name: 'playwrightMockingProxy',
hooks: {
'astro:server:setup': async astro => {
if (process.env.NODE_ENV !== 'test')
return;

const proxyStorage = new AsyncLocalStorage<string>();
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const proxy = proxyStorage.getStore();
if (!proxy)
return originalFetch(input, init);
const request = new Request(input, init);
return originalFetch(proxy + request.url, request);
};
astro.server.middlewares.use((req, res, next) => {
const header = req.headers['x-playwright-proxy'] as string;
if (typeof header !== 'string')
return next();
proxyStorage.run(decodeURIComponent(header), next);
});
},
}
};

export default defineConfig({
integrations: [
playwrightMockingProxy
]
});
```

#### Nuxt

```js
// server/plugins/playwright-mocking-proxy.ts

import { getGlobalDispatcher, setGlobalDispatcher } from "undici"
import { useEvent, getRequestHeader } from '#imports'

export default defineNitroPlugin(() => {
if (process.env.NODE_ENV !== 'test')
return;

const proxiedDispatcher = getGlobalDispatcher().compose(dispatch => (opts, handler) => {
const isInternal = opts.path.startsWith("/__nuxt")
const proxy = getRequestHeader(useEvent(), 'x-playwright-proxy')
if (proxy && !isInternal) {
const newURL = new URL(decodeURIComponent(proxy) + opts.origin + opts.path);
opts.origin = newURL.origin;
opts.path = newURL.pathname;
}
return dispatch(opts, handler)
})
setGlobalDispatcher(proxiedDispatcher)
});
```

```js
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
asyncContext: true,
}
}
})
```
16 changes: 16 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,19 @@ export default defineConfig({
},
});
```

## property: TestOptions.mockingProxy
* since: v1.51
- type: <[MockingProxyMode]<"off"|"inject-via-header">> Enables the mocking proxy. Playwright will 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: 'inject-via-header'
},
});
```
Loading
Loading