Skip to content

Commit

Permalink
Concept: test mode for Playwright and similar integration tools (#52520)
Browse files Browse the repository at this point in the history
An experimental test mode that enables integration tests to mock server-side fetch requests in Playwright tests.

For explanation on how this works, see the [`next-playwright/README.md`](https://github.com/vercel/next.js/pull/52520/files#diff-3b8da7782c16f015df5afafe0ac11247f5b8e5a1c0dbede341ca2b5124dfd924).
  • Loading branch information
dvoytenko authored Aug 14, 2023
1 parent 3958fc0 commit 88ad471
Show file tree
Hide file tree
Showing 28 changed files with 1,071 additions and 82 deletions.
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/playwright.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../dist/experimental/testmode/playwright'
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/playwright.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../dist/experimental/testmode/playwright')
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/playwright/msw.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../../dist/experimental/testmode/playwright/msw'
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/playwright/msw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../../dist/experimental/testmode/playwright/msw')
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/proxy.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../dist/experimental/testmode/proxy'
1 change: 1 addition & 0 deletions packages/next/experimental/testmode/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../dist/experimental/testmode/proxy')
11 changes: 10 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@
"headers.d.ts",
"navigation-types",
"web-vitals.js",
"web-vitals.d.ts"
"web-vitals.d.ts",
"experimental/testmode/playwright.js",
"experimental/testmode/playwright.d.ts",
"experimental/testmode/playwright/msw.js",
"experimental/testmode/playwright/msw.d.ts",
"experimental/testmode/proxy.js",
"experimental/testmode/proxy.d.ts"
],
"bin": {
"next": "./dist/bin/next"
Expand Down Expand Up @@ -143,6 +149,7 @@
"@next/react-refresh-utils": "13.4.15",
"@next/swc": "13.4.15",
"@opentelemetry/api": "1.4.1",
"@playwright/test": "^1.35.1",
"@segment/ajv-human-errors": "2.1.2",
"@taskr/clear": "1.1.0",
"@taskr/esnext": "1.1.0",
Expand Down Expand Up @@ -245,6 +252,7 @@
"lru-cache": "5.1.1",
"micromatch": "4.0.4",
"mini-css-extract-plugin": "2.4.3",
"msw": "^1.2.2",
"nanoid": "3.1.32",
"native-url": "0.3.4",
"neo-async": "2.6.1",
Expand Down Expand Up @@ -283,6 +291,7 @@
"stacktrace-parser": "0.1.10",
"stream-browserify": "3.0.0",
"stream-http": "3.1.1",
"strict-event-emitter": "0.5.0",
"string-hash": "1.1.3",
"string_decoder": "1.3.0",
"strip-ansi": "6.0.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/next/src/cli/next-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ const nextDev: CliCommand = async (argv) => {
'--hostname': String,
'--turbo': Boolean,
'--experimental-turbo': Boolean,
'--experimental-test-proxy': Boolean,

// To align current messages with native binary.
// Will need to adjust subcommand later.
Expand Down Expand Up @@ -274,13 +275,15 @@ const nextDev: CliCommand = async (argv) => {
// some set-ups that rely on listening on other interfaces
const host = args['--hostname']
config = await loadConfig(PHASE_DEVELOPMENT_SERVER, dir)
const isExperimentalTestProxy = args['--experimental-test-proxy']

const devServerOptions: StartServerOptions = {
dir,
port,
allowRetry,
isDev: true,
hostname: host,
isExperimentalTestProxy,
}

if (args['--turbo']) {
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/cli/next-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const nextStart: CliCommand = async (argv) => {
'--port': Number,
'--hostname': String,
'--keepAliveTimeout': Number,
'--experimental-test-proxy': Boolean,

// Aliases
'-h': '--help',
Expand Down Expand Up @@ -46,6 +47,8 @@ const nextStart: CliCommand = async (argv) => {
const host = args['--hostname']
const port = getPort(args)

const isExperimentalTestProxy = args['--experimental-test-proxy']

const keepAliveTimeoutArg: number | undefined = args['--keepAliveTimeout']
if (
typeof keepAliveTimeoutArg !== 'undefined' &&
Expand All @@ -66,6 +69,7 @@ const nextStart: CliCommand = async (argv) => {
await startServer({
dir,
isDev: false,
isExperimentalTestProxy,
hostname: host,
port,
keepAliveTimeout,
Expand Down
96 changes: 96 additions & 0 deletions packages/next/src/experimental/testmode/playwright/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Experimental test mode for Playwright

### Prerequisites

You have a Next.js project.

### Install `@playwright/test` in your project

```sh
npm install -D @playwright/test
```

### Optionally install MSW in your project

[MSW](https://mswjs.io/) can be helpful for fetch mocking.

```sh
npm install -D msw
```

### Update `playwright.config.ts`

```javascript
import { defineConfig } from 'next/experimental/testmode/playwright'

export default defineConfig({
webServer: {
command: 'npm dev -- --experimental-test-proxy',
url: 'http://localhost:3000',
},
})
```

### Use the `next/experimental/testmode/playwright` to create tests

```javascript
import { test, expect } from 'next/experimental/testmode/playwright'

test('/product/shoe', async ({ page, next }) => {
next.onFetch((request) => {
if (request.url === 'http://my-db/product/shoe') {
return new Response(
JSON.stringify({
title: 'A shoe',
}),
{
headers: {
'Content-Type': 'application/json',
},
}
)
}
return 'abort'
})

await page.goto('/product/shoe')

await expect(page.locator('body')).toHaveText(/Shoe/)
})
```

### Or use the `next/experimental/testmode/playwright/msw`

```javascript
import { test, expect, rest } from 'next/experimental/testmode/playwright/msw'

test.use({
mswHandlers: [
rest.get('http://my-db/product/shoe', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
title: 'A shoe',
})
)
}),
],
})

test('/product/shoe', async ({ page, msw }) => {
msw.use(
rest.get('http://my-db/product/boot', (req, res, ctx) => {
return res.once(
ctx.status(200),
ctx.json({
title: 'A boot',
})
)
})
)

await page.goto('/product/boot')

await expect(page.locator('body')).toHaveText(/Boot/)
})
```
36 changes: 36 additions & 0 deletions packages/next/src/experimental/testmode/playwright/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { test as base } from '@playwright/test'
import type { NextFixture } from './next-fixture'
import type { NextWorkerFixture } from './next-worker-fixture'
import { applyNextWorkerFixture } from './next-worker-fixture'
import { applyNextFixture } from './next-fixture'

// eslint-disable-next-line import/no-extraneous-dependencies
export * from '@playwright/test'

export type { NextFixture }
export type { FetchHandlerResult } from '../proxy'

export const test = base.extend<
{ next: NextFixture },
{ _nextWorker: NextWorkerFixture }
>({
_nextWorker: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
await applyNextWorkerFixture(use)
},
{ scope: 'worker', auto: true },
],

next: async ({ _nextWorker, page, extraHTTPHeaders }, use, testInfo) => {
await applyNextFixture(use, {
testInfo,
nextWorker: _nextWorker,
page,
extraHTTPHeaders,
})
},
})

export default test
115 changes: 115 additions & 0 deletions packages/next/src/experimental/testmode/playwright/msw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { test as base } from './index'
import type { NextFixture } from './next-fixture'
import {
type RequestHandler,
type MockedResponse,
MockedRequest,
handleRequest,
// eslint-disable-next-line import/no-extraneous-dependencies
} from 'msw'
// eslint-disable-next-line import/no-extraneous-dependencies
import { Emitter } from 'strict-event-emitter'

// eslint-disable-next-line import/no-extraneous-dependencies
export * from 'msw'
// eslint-disable-next-line import/no-extraneous-dependencies
export * from '@playwright/test'
export type { NextFixture }

export interface MswFixture {
use: (...handlers: RequestHandler[]) => void
}

export const test = base.extend<{
msw: MswFixture
mswHandlers: RequestHandler[]
}>({
mswHandlers: [],

msw: [
async ({ next, mswHandlers }, use) => {
const handlers: RequestHandler[] = [...mswHandlers]
const emitter = new Emitter()

next.onFetch(async (request) => {
const {
body,
method,
headers,
credentials,
cache,
redirect,
integrity,
keepalive,
mode,
destination,
referrer,
referrerPolicy,
} = request
const mockedRequest = new MockedRequest(new URL(request.url), {
body: body ? await request.arrayBuffer() : undefined,
method,
headers: Object.fromEntries(headers),
credentials,
cache,
redirect,
integrity,
keepalive,
mode,
destination,
referrer,
referrerPolicy,
})
let isPassthrough = false
let mockedResponse: MockedResponse | undefined
await handleRequest(
mockedRequest,
handlers.slice(0),
{ onUnhandledRequest: 'error' },
emitter as any,
{
onPassthroughResponse: () => {
isPassthrough = true
},
onMockedResponse: (r) => {
mockedResponse = r
},
}
)

if (isPassthrough) {
return 'continue'
}

if (mockedResponse) {
const {
status,
headers: responseHeaders,
body: responseBody,
delay,
} = mockedResponse
if (delay) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
return new Response(responseBody, {
status,
headers: new Headers(responseHeaders),
})
}

return 'abort'
})

await use({
use: (...newHandlers) => {
handlers.unshift(...newHandlers)
},
})

handlers.length = 0
},
{ auto: true },
],
})

export default test
Loading

0 comments on commit 88ad471

Please sign in to comment.