Skip to content

Commit

Permalink
Concept: test mode for Playwright and similar integration tools
Browse files Browse the repository at this point in the history
  • Loading branch information
dvoytenko committed Aug 1, 2023
1 parent fc52e02 commit 9629779
Show file tree
Hide file tree
Showing 27 changed files with 6,714 additions and 6,876 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')
9 changes: 8 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@
"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"
],
"bin": {
"next": "./dist/bin/next"
Expand Down Expand Up @@ -143,6 +147,7 @@
"@next/react-refresh-utils": "13.4.13-canary.8",
"@next/swc": "13.4.13-canary.8",
"@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 @@ -246,6 +251,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 @@ -285,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 @@ -120,6 +120,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 @@ -203,6 +204,7 @@ 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,
Expand All @@ -213,6 +215,7 @@ const nextDev: CliCommand = async (argv) => {
hostname: host,
// This is required especially for app dir.
useWorkers: true,
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 @@ -17,6 +17,7 @@ const nextStart: CliCommand = async (argv) => {
'--port': Number,
'--hostname': String,
'--keepAliveTimeout': Number,
'--experimental-test-proxy': Boolean,

// Aliases
'-h': '--help',
Expand Down Expand Up @@ -49,6 +50,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 Down Expand Up @@ -78,6 +81,7 @@ const nextStart: CliCommand = async (argv) => {
dir,
nextConfig: config,
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: 'pnpm 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/)
})
```
34 changes: 34 additions & 0 deletions packages/next/src/experimental/testmode/playwright/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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'

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
111 changes: 111 additions & 0 deletions packages/next/src/experimental/testmode/playwright/msw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { test as base } from './index'
import type { NextFixture } from './next-fixture'
import {
type RequestHandler,
type MockedResponse,
MockedRequest,
handleRequest,
} from 'msw'
import { Emitter } from 'strict-event-emitter'

export * from 'msw'
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
56 changes: 56 additions & 0 deletions packages/next/src/experimental/testmode/playwright/next-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Page, TestInfo } from '@playwright/test'
import type { NextWorkerFixture, FetchHandler } from './next-worker-fixture'
import { handleRoute } from './page-route'

export interface NextFixture {
onFetch: (handler: FetchHandler) => void
}

class NextFixtureImpl implements NextFixture {
private fetchHandler: FetchHandler | null = null

constructor(
public testId: string,
private worker: NextWorkerFixture,
private page: Page
) {
this.page.route('**', (route) =>
handleRoute(route, page, this.fetchHandler)
)
}

teardown(): void {
this.worker.cleanupTest(this.testId)
}

onFetch(handler: FetchHandler): void {
this.fetchHandler = handler
this.worker.onFetch(this.testId, handler)
}
}

export async function applyNextFixture(
use: (fixture: NextFixture) => Promise<void>,
{
testInfo,
nextWorker,
page,
extraHTTPHeaders,
}: {
testInfo: TestInfo
nextWorker: NextWorkerFixture
page: Page
extraHTTPHeaders: Record<string, string> | undefined
}
): Promise<void> {
const fixture = new NextFixtureImpl(testInfo.testId, nextWorker, page)
page.setExtraHTTPHeaders({
...extraHTTPHeaders,
'Next-Test-Proxy-Port': String(nextWorker.proxyPort),
'Next-Test-Data': fixture.testId,
})

await use(fixture)

fixture.teardown()
}
Loading

0 comments on commit 9629779

Please sign in to comment.