Skip to content

Commit 5037604

Browse files
committed
Concept: test mode for Playwright and similar integration tools
1 parent 3a30308 commit 5037604

File tree

27 files changed

+6709
-6872
lines changed

27 files changed

+6709
-6872
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from '../../dist/experimental/testmode/playwright'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('../../dist/experimental/testmode/playwright')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from '../../../dist/experimental/testmode/playwright/msw'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('../../../dist/experimental/testmode/playwright/msw')

packages/next/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@
6161
"headers.d.ts",
6262
"navigation-types",
6363
"web-vitals.js",
64-
"web-vitals.d.ts"
64+
"web-vitals.d.ts",
65+
"experimental/testmode/playwright.js",
66+
"experimental/testmode/playwright.d.ts",
67+
"experimental/testmode/playwright/msw.js",
68+
"experimental/testmode/playwright/msw.d.ts"
6569
],
6670
"bin": {
6771
"next": "./dist/bin/next"
@@ -143,6 +147,7 @@
143147
"@next/react-refresh-utils": "13.4.13-canary.9",
144148
"@next/swc": "13.4.13-canary.9",
145149
"@opentelemetry/api": "1.4.1",
150+
"@playwright/test": "^1.35.1",
146151
"@segment/ajv-human-errors": "2.1.2",
147152
"@taskr/clear": "1.1.0",
148153
"@taskr/esnext": "1.1.0",
@@ -246,6 +251,7 @@
246251
"lru-cache": "5.1.1",
247252
"micromatch": "4.0.4",
248253
"mini-css-extract-plugin": "2.4.3",
254+
"msw": "^1.2.2",
249255
"nanoid": "3.1.32",
250256
"native-url": "0.3.4",
251257
"neo-async": "2.6.1",
@@ -285,6 +291,7 @@
285291
"stacktrace-parser": "0.1.10",
286292
"stream-browserify": "3.0.0",
287293
"stream-http": "3.1.1",
294+
"strict-event-emitter": "0.5.0",
288295
"string-hash": "1.1.3",
289296
"string_decoder": "1.3.0",
290297
"strip-ansi": "6.0.0",

packages/next/src/cli/next-dev.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const nextDev: CliCommand = async (argv) => {
120120
'--hostname': String,
121121
'--turbo': Boolean,
122122
'--experimental-turbo': Boolean,
123+
'--experimental-test-proxy': Boolean,
123124

124125
// To align current messages with native binary.
125126
// Will need to adjust subcommand later.
@@ -203,6 +204,7 @@ const nextDev: CliCommand = async (argv) => {
203204
// some set-ups that rely on listening on other interfaces
204205
const host = args['--hostname']
205206
config = await loadConfig(PHASE_DEVELOPMENT_SERVER, dir)
207+
const isExperimentalTestProxy = args['--experimental-test-proxy']
206208

207209
const devServerOptions: StartServerOptions = {
208210
dir,
@@ -213,6 +215,7 @@ const nextDev: CliCommand = async (argv) => {
213215
hostname: host,
214216
// This is required especially for app dir.
215217
useWorkers: true,
218+
isExperimentalTestProxy,
216219
}
217220

218221
if (args['--turbo']) {

packages/next/src/cli/next-start.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const nextStart: CliCommand = async (argv) => {
1717
'--port': Number,
1818
'--hostname': String,
1919
'--keepAliveTimeout': Number,
20+
'--experimental-test-proxy': Boolean,
2021

2122
// Aliases
2223
'-h': '--help',
@@ -49,6 +50,8 @@ const nextStart: CliCommand = async (argv) => {
4950
const host = args['--hostname']
5051
const port = getPort(args)
5152

53+
const isExperimentalTestProxy = args['--experimental-test-proxy']
54+
5255
const keepAliveTimeoutArg: number | undefined = args['--keepAliveTimeout']
5356
if (
5457
typeof keepAliveTimeoutArg !== 'undefined' &&
@@ -78,6 +81,7 @@ const nextStart: CliCommand = async (argv) => {
7881
dir,
7982
nextConfig: config,
8083
isDev: false,
84+
isExperimentalTestProxy,
8185
hostname: host,
8286
port,
8387
keepAliveTimeout,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Experimental test mode for Playwright
2+
3+
### Prerequisites
4+
5+
You have a Next.js project.
6+
7+
### Install `@playwright/test` in your project
8+
9+
```sh
10+
npm install -D @playwright/test
11+
```
12+
13+
### Optionally install MSW in your project
14+
15+
[MSW](https://mswjs.io/) can be helpful for fetch mocking.
16+
17+
```sh
18+
npm install -D msw
19+
```
20+
21+
### Update `playwright.config.ts`
22+
23+
```javascript
24+
import { defineConfig } from 'next/experimental/testmode/playwright'
25+
26+
export default defineConfig({
27+
webServer: {
28+
command: 'pnpm dev -- --experimental-test-proxy',
29+
url: 'http://localhost:3000',
30+
},
31+
})
32+
```
33+
34+
### Use the `next/experimental/testmode/playwright` to create tests
35+
36+
```javascript
37+
import { test, expect } from 'next/experimental/testmode/playwright'
38+
39+
test('/product/shoe', async ({ page, next }) => {
40+
next.onFetch((request) => {
41+
if (request.url === 'http://my-db/product/shoe') {
42+
return new Response(
43+
JSON.stringify({
44+
title: 'A shoe',
45+
}),
46+
{
47+
headers: {
48+
'Content-Type': 'application/json',
49+
},
50+
}
51+
)
52+
}
53+
return 'abort'
54+
})
55+
56+
await page.goto('/product/shoe')
57+
58+
await expect(page.locator('body')).toHaveText(/Shoe/)
59+
})
60+
```
61+
62+
### Or use the `next/experimental/testmode/playwright/msw`
63+
64+
```javascript
65+
import { test, expect, rest } from 'next/experimental/testmode/playwright/msw'
66+
67+
test.use({
68+
mswHandlers: [
69+
rest.get('http://my-db/product/shoe', (req, res, ctx) => {
70+
return res(
71+
ctx.status(200),
72+
ctx.json({
73+
title: 'A shoe',
74+
})
75+
)
76+
}),
77+
],
78+
})
79+
80+
test('/product/shoe', async ({ page, msw }) => {
81+
msw.use(
82+
rest.get('http://my-db/product/boot', (req, res, ctx) => {
83+
return res.once(
84+
ctx.status(200),
85+
ctx.json({
86+
title: 'A boot',
87+
})
88+
)
89+
})
90+
)
91+
92+
await page.goto('/product/boot')
93+
94+
await expect(page.locator('body')).toHaveText(/Boot/)
95+
})
96+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test as base } from '@playwright/test'
2+
import type { NextFixture } from './next-fixture'
3+
import type { NextWorkerFixture } from './next-worker-fixture'
4+
import { applyNextWorkerFixture } from './next-worker-fixture'
5+
import { applyNextFixture } from './next-fixture'
6+
7+
export * from '@playwright/test'
8+
9+
export type { NextFixture }
10+
export type { FetchHandlerResult } from '../proxy'
11+
12+
export const test = base.extend<
13+
{ next: NextFixture },
14+
{ _nextWorker: NextWorkerFixture }
15+
>({
16+
_nextWorker: [
17+
// eslint-disable-next-line no-empty-pattern
18+
async ({}, use) => {
19+
await applyNextWorkerFixture(use)
20+
},
21+
{ scope: 'worker', auto: true },
22+
],
23+
24+
next: async ({ _nextWorker, page, extraHTTPHeaders }, use, testInfo) => {
25+
await applyNextFixture(use, {
26+
testInfo,
27+
nextWorker: _nextWorker,
28+
page,
29+
extraHTTPHeaders,
30+
})
31+
},
32+
})
33+
34+
export default test
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { test as base } from './index'
2+
import type { NextFixture } from './next-fixture'
3+
import {
4+
type RequestHandler,
5+
type MockedResponse,
6+
MockedRequest,
7+
handleRequest,
8+
} from 'msw'
9+
import { Emitter } from 'strict-event-emitter'
10+
11+
export * from 'msw'
12+
export * from '@playwright/test'
13+
export type { NextFixture }
14+
15+
export interface MswFixture {
16+
use: (...handlers: RequestHandler[]) => void
17+
}
18+
19+
export const test = base.extend<{
20+
msw: MswFixture
21+
mswHandlers: RequestHandler[]
22+
}>({
23+
mswHandlers: [],
24+
25+
msw: [
26+
async ({ next, mswHandlers }, use) => {
27+
const handlers: RequestHandler[] = [...mswHandlers]
28+
const emitter = new Emitter()
29+
30+
next.onFetch(async (request) => {
31+
const {
32+
body,
33+
method,
34+
headers,
35+
credentials,
36+
cache,
37+
redirect,
38+
integrity,
39+
keepalive,
40+
mode,
41+
destination,
42+
referrer,
43+
referrerPolicy,
44+
} = request
45+
const mockedRequest = new MockedRequest(new URL(request.url), {
46+
body: body ? await request.arrayBuffer() : undefined,
47+
method,
48+
headers: Object.fromEntries(headers),
49+
credentials,
50+
cache,
51+
redirect,
52+
integrity,
53+
keepalive,
54+
mode,
55+
destination,
56+
referrer,
57+
referrerPolicy,
58+
})
59+
let isPassthrough = false
60+
let mockedResponse: MockedResponse | undefined
61+
await handleRequest(
62+
mockedRequest,
63+
handlers.slice(0),
64+
{ onUnhandledRequest: 'error' },
65+
emitter as any,
66+
{
67+
onPassthroughResponse: () => {
68+
isPassthrough = true
69+
},
70+
onMockedResponse: (r) => {
71+
mockedResponse = r
72+
},
73+
}
74+
)
75+
76+
if (isPassthrough) {
77+
return 'continue'
78+
}
79+
80+
if (mockedResponse) {
81+
const {
82+
status,
83+
headers: responseHeaders,
84+
body: responseBody,
85+
delay,
86+
} = mockedResponse
87+
if (delay) {
88+
await new Promise((resolve) => setTimeout(resolve, delay))
89+
}
90+
return new Response(responseBody, {
91+
status,
92+
headers: new Headers(responseHeaders),
93+
})
94+
}
95+
96+
return 'abort'
97+
})
98+
99+
await use({
100+
use: (...newHandlers) => {
101+
handlers.unshift(...newHandlers)
102+
},
103+
})
104+
105+
handlers.length = 0
106+
},
107+
{ auto: true },
108+
],
109+
})
110+
111+
export default test

0 commit comments

Comments
 (0)