-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Add new route interceptor features #15
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
22 changes: 22 additions & 0 deletions
22
data/test/validation/route-interceptor/Route_interceptors/With_aborted_request.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: -" | ||
| } | ||
| ] | ||
| } |
23 changes: 23 additions & 0 deletions
23
data/test/validation/route-interceptor/Route_interceptors/With_barrier.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }" | ||
| ] | ||
| } |
11 changes: 11 additions & 0 deletions
11
data/test/validation/route-interceptor/Route_interceptors/With_incomplete_json_response.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)" | ||
| } | ||
| ] | ||
| } | ||
23 changes: 23 additions & 0 deletions
23
data/test/validation/route-interceptor/Route_interceptors/With_mocked_route.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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\" }" | ||
| ] | ||
| } |
23 changes: 23 additions & 0 deletions
23
data/test/validation/route-interceptor/Route_interceptors/With_overwritten_response.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }" | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.