Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/new-cups-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cronn/playwright-utils": minor
---

Add features to intercept network requests for the duration of a callback
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"region": "Api Response",
"children": [
{
"heading": "Api Response",
"level": 2
},
{
"label": [
"Count:",
{
"textbox": "Count:",
"value": "1",
"readonly": true
}
]
},
{
"paragraph": "Status: -"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"region": "Api Response",
"children": [
{
"heading": "Api Response",
"level": 2
},
{
"label": [
"Count:",
{
"textbox": "Count:",
"value": "4",
"readonly": true
}
]
},
{
"paragraph": "Status: 200"
},
"{ \"username\": \"test_user\", \"enabled\": false, \"id\": 1 }"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"alert": [
{
"heading": "Alert",
"level": 2
},
{
"paragraph": "SyntaxError: Unterminated string in JSON at position 5 (line 1 column 6)"
Comment thread
foxable marked this conversation as resolved.
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"region": "Api Response",
"children": [
{
"heading": "Api Response",
"level": 2
},
{
"label": [
"Count:",
{
"textbox": "Count:",
"value": "2",
"readonly": true
}
]
},
{
"paragraph": "Status: 500"
},
"{ \"error\": \"Internal Server Error\" }"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"region": "Api Response",
"children": [
{
"heading": "Api Response",
"level": 2
},
{
"label": [
"Count:",
{
"textbox": "Count:",
"value": "3",
"readonly": true
}
]
},
{
"paragraph": "Status: 200"
},
"{ \"username\": \"test_user\", \"enabled\": false, \"id\": 1 }"
]
}
162 changes: 162 additions & 0 deletions docs/src/api/route-interceptor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Route Interceptor

Playwright's [`page.route`](https://playwright.dev/docs/network#network-mocking) installs a route handler for the whole page, which has to be removed again with `page.unroute` once the test no longer needs it.
This library provides various utilities to scope these route interceptors to a function callback, to temporarily modify the behavior of a network request.

This keeps mocking close to the assertions it belongs to and makes it possible to change the behavior of the same route multiple times within one test.

## Usage

```ts
import { interceptRoute, matchPath } from "@cronn/playwright-utils";
import { expect, test } from "@playwright/test";

test("shows an error when the user cannot be loaded", async ({ page }) => {
await interceptRoute(page, matchPath("/api/users/1"))
.abort()
.during(async () => {
await page.goto("/users/1/profile");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Unknown user",
);
});
});
```

Requests which are not matched by the filter are passed on to the next matching route handler, so interceptions can be nested and combined with route handlers registered elsewhere.

## Filtering requests

A `RouteFilter` selects the requests to intercept. `matchPath` creates a filter matching the path of a URL, either exactly or by regular expression. Query parameters, host and protocol are ignored:

```ts
import { matchPath } from "@cronn/playwright-utils";

matchPath("/api/users/1");
matchPath(/^\/api\/users\/\d+$/);
```

Filters can also be written by hand, for example to restrict an interception to certain HTTP methods:

```ts
import type { RouteFilter } from "@cronn/playwright-utils";

const onCreateOrUpdateUser: RouteFilter = {
method: ["POST", "PUT"],
url: (url) => url.pathname.startsWith("/api/users"),
};
```

| Property | Type | Description |
| -------- | --------------------------------- | --------------------------------------------------- |
| `url` | `(url: URL) => boolean` | Decides whether the URL of a request is matched |
| `method` | `HttpMethod \| Array<HttpMethod>` | Optional. Matches requests of any method if omitted |

## Interceptions

All methods of a `RouteInterceptor` take the action to run while the interception is installed and return the value of that action.

### Aborting requests

`abort` fails matching requests, which is useful for testing how the application under test handles network errors:

```ts
await interceptRoute(page, matchPath("/api/users/1"))
.abort()
.during(async () => {
await page.getByRole("button", { name: "Fetch user" }).click();
await expect(page.getByRole("alert")).toBeVisible();
});
```

### Mocking responses

`respondWith` answers matching requests with a mocked response, which never reaches the server. It accepts the options of [`route.fulfill`](https://playwright.dev/docs/api/class-route#route-fulfill):

```ts
await interceptRoute(page, matchPath("/api/users/1"))
.respondWith({ status: 500 })
.during(async () => {
await page.getByRole("button", { name: "Fetch user" }).click();
await expect(page.getByRole("alert")).toHaveText("Something went wrong");
});
```

### Modifying responses

`modifyResponse` sends the request to the server and passes it to a modifier before it is returned to the page.

```ts
await interceptRoute(page, matchPath("/api/users/1"))
.modifyResponse(
// helper to easily modify the response body with a typed function
modifyJsonBody<ApiUserResponse>((user) => ({
...user, // keep original response from server
enabled: false, // only modify one field
})),
)
.during(async () => {
await page.getByRole("button", { name: "Fetch user" }).click();
await expect(
page.getByRole("checkbox", { name: "Enabled" }),
).not.toBeChecked();
});
```

### Suspending requests

`suspend` delays matching requests until the action has finished. This makes pending states such as loading indicators, skeletons or disabled buttons observable without relying on timing:

```ts
await interceptRoute(page, matchPath("/api/users/1"))
.suspend()
.during(async () => {
await page.getByRole("button", { name: "Fetch user" }).click();
// The loading indicator can be inspected without worrying about race-conditions
await expect(page.getByRole("progressbar")).toBeVisible();
});

await expect(page.getByRole("progressbar")).toHaveCount(0);
```

The suspended requests are continued once the action returns, and `suspend` resolves after all of them have been answered. Assertions on the loaded state therefore belong after the action, not inside it.

## Fixture

`RouteInterceptorFixture` creates interceptors for a page. Extending it gives the routes of the application under test descriptive names, so tests do not have to repeat their URL patterns:

```ts
import {
matchPath,
type RouteInterceptor,
RouteInterceptorFixture,
} from "@cronn/playwright-utils";
import { test as base } from "@playwright/test";

class AppInterceptorFixture extends RouteInterceptorFixture {
public onGetUser(userId = 1): RouteInterceptor {
return this.intercept(matchPath(`/api/users/${userId}`));
}
}

export const test = base.extend<{ intercept: AppInterceptorFixture }>({
intercept: ({ page }, use) => use(new AppInterceptorFixture(page)),
});
```

The fixture is then available in every test:

```ts
test("shows an error when the user cannot be loaded", async ({
page,
intercept,
}) => {
await intercept
.onGetUser()
.abort()
.during(async () => {
await page.getByRole("button", { name: "Fetch user" }).click();
await expect(page.getByRole("alert")).toBeVisible();
});
});
```
3 changes: 3 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ features:
- title: API Testing
details: Use Playwright's request context as a fetch implementation to send requests of an API client through Playwright.
link: /api/fetch-adapter
- title: Network Interceptors
details: Abort, mock, modify or delay the requests of a page for the duration of a single action instead of the whole test.
link: /api/route-interceptor
- title: Snapshot Testing
details: Mask non-deterministic values like IDs, timestamps or base URLs in a consistent format to keep file snapshots stable.
link: /snapshots/normalizers
Expand Down
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig(
"coverage",
"docs/.vitepress/cache",
"docs/.vitepress/dist",
"docs/.vitepress/.temp",
"playwright-report",
]),
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"devDependencies": {
"@arethetypeswrong/core": "0.18.5",
"@changesets/cli": "3.0.1",
"@cronn/element-snapshot": "0.27.0",
"@cronn/playwright-file-snapshots": "2.2.1",
"@playwright/test": "1.62.1",
"@types/node": "24.13.3",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions src/api/route-interceptor/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* HTTP Methods used by {@link RouteFilter}
*/
export type HttpMethod =
| "GET"
| "PATCH"
| "POST"
| "PUT"
| "DELETE"
| "OPTIONS"
| "QUERY"
| "HEAD";

/**
* Filters used to intercept routes with {@link RouteInterceptor}.
*
* @see matchPath
*/
export interface RouteFilter {
method?: HttpMethod | Array<HttpMethod>;
url: (url: URL) => boolean;
}

/**
* Helper to create a {@link RouteFilter} that applies to the specified path.
*
* @param pattern - The regex or string matching the pathname of the URL
* @returns RouteFilter
* @example
* ```ts
* await interceptRoute(page, matchPath("/users/1"))
* .respondWith({ status: 404 })
* .during(() => {
* // ...
* });
* ```
*/
export function matchPath(pattern: RegExp | string): RouteFilter {
return {
url: (url) =>
typeof pattern === "string"
? url.pathname === pattern
: url.pathname.match(pattern) !== null,
};
}
Loading
Loading