Skip to content

Commit

Permalink
fix(47299): allow testing pages with metadata in jsdom test environme…
Browse files Browse the repository at this point in the history
…nt (#53578)

### 🧐 What's in there?

This is another attempt to allow testing server-only code with Jest.

### 🧪 How to test?

There's an integration tests which can be triggered with `pnpm testheadless server-only`

Here is a more comprehensive setup:
<details>
<summary><code>app/lib/index.ts</code></summary>

```ts
import 'server-only'

export function add(num1: number, num2: number) {
  return num1 + num2
}
```
</details>
<details>
<summary><code>app/lib/index.test.ts</code></summary>

```ts
import { add } from '.'

it('adds two numbers', () => {
  expect(add(1, 3)).toEqual(4)
})
```
</details>
<details>
<summary><code>app/client-component.tsx</code></summary>

```ts
'use client'

import { useState } from 'react'

export default function ClientComponent() {
  const [text, setText] = useState('not clicked yet')
  return <button onClick={() => setText('clicked!')}>{text}</button>
}
```
</details>
<details>
<summary><code>app/client-component.test.tsx</code></summary>

```ts
import { fireEvent, render, screen } from '@testing-library/react'
import ClientComponent from './client-component'

it('can be clicked', async () => {
  render(<ClientComponent />)
  const button = screen.getByRole('button')
  expect(button).toHaveTextContent('not clicked yet')
  await fireEvent.click(button)
  expect(button).toHaveTextContent('clicked!')
})
```
</details>
<details>
<summary><code>app/server-component.tsx</code></summary>

```ts
import { add } from '@/lib'

export default function ServerComponent({ a, b }: { a: number; b: number }) {
  return (
    <code role="comment">
      {a} + {b} = {add(a, b)}
    </code>
  )
}
```
</details>
<details>
<summary><code>app/server-component.test.tsx</code></summary>

```ts
import { render, screen } from '@testing-library/react'
import ServerComponent from './server-component'

it('renders', () => {
  render(<ServerComponent a={2} b={3} />)
  expect(screen.getByRole('comment')).toHaveTextContent('2 + 3 = 5')
})
```
</details>
<details>
<summary><code>app/page.tsx</code></summary>

```ts
import Link from 'next/link'
import ClientComponent from './client-component'
import ServerComponent from './server-component'

export default function Page() {
  return (
    <>
      <h1>Hello World</h1>
      <Link href="/dave">Dave?</Link>
      <p>
        <ClientComponent />
      </p>
      <p>
        <ServerComponent a={5} b={2} />
      </p>
    </>
  )
}
```
</details>
<details>
<summary><code>app/page.test.tsx</code></summary>

```ts
import { render, screen } from '@testing-library/react'
import Page from './page'

it('greets', () => {
  render(<Page />)
  expect(screen.getByRole('link')).toHaveTextContent('Dave?')
  expect(screen.getByRole('heading')).toHaveTextContent('Hello World')
  expect(screen.getByRole('button')).toHaveTextContent('not clicked yet')
  expect(screen.getByRole('comment')).toHaveTextContent('5 + 2 = 7')
})
```
</details>
<details>
<summary><code>app/[blog]/page.tsx</code></summary>

```ts
import { Metadata } from 'next'
import Link from 'next/link'

type Props = {
  params: { blog: string }
}

export async function generateMetadata({
  params: { blog: title },
}: Props): Promise<Metadata> {
  return { title, description: `A blog post about ${title}` }
}

export default function Page({ params }: Props) {
  return (
    <>
      <div>
        <Link href="/">Back</Link>
      </div>
      <h1>All about {params.blog}</h1>
    </>
  )
}
```
</details>
<details>
<summary><code>app/[blog]/page.test.tsx</code></summary>

```ts
import { render, screen } from '@testing-library/react'
import Page from './page'

it('has the appropriate title', () => {
  const title = 'Jane'
  render(<Page params={{ blog: title }} />)
  expect(screen.getByRole('heading')).toHaveTextContent(`All about ${title}`)
  expect(screen.getByRole('link')).toHaveTextContent('Back')
})
```
</details>
<details>
<summary><code>app/layout.tsx</code></summary>

```ts
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}
```
</details>
<details>
<summary><code>jest.config.js</code></summary>

```ts
const nextJest = require('next/jest')

const createJestConfig = nextJest({ dir: './' })

module.exports = createJestConfig({
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/test-setup.ts'],
})
```
</details>
<details>
<summary><code>package.json</code></summary>

```ts
{
  "name": "rsc-test",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "latest"
  }
}
```
</details>
<details>
<summary><code>test-setup.ts</code></summary>

```ts
import '@testing-library/jest-dom'
```
</details>
   
The app should run and all test should pass.

### ❗ Notes to reviewers

#### The problem:

1. next/jest configures jest with a transformer ([jest-transformer](https://github.com/vercel/next.js/blob/canary/packages/next/src/build/swc/jest-transformer.ts)) to compile react code with next -swc
2. the transformers configures next -swc for a given environment: Server or Client, based on jest global environment
3. Based on the environment, next -swc checks for invalid usage of `import('server-only')`  `“use client”`, `export const metadata` or `export async function generateMetadata` 
4. Because the global test environment is either jsdom or node, the same test suite can not include both client and server components

#### Possible mitigations

*A. Using jest projects*

When configured with [multiple projects](https://jestjs.io/docs/next/configuration/#projects-arraystring--projectconfig), Jest can launch different runners with different environment. This would allow running server-only code in node and client-only code in jsdom. 

However, it requires user to completely change their jest configuration. It would also require a different setup when scaffolding new app-directory project with create-next.

*B. Using doc blocks*

Jest allows changing the environment per test file [with docBlock](https://jestjs.io/docs/configuration#testenvironment-string). 

However, by the time jest is invoking next -swc on a source file to transform it, this information is gone, and next -swc is still invoked with the (wrong) global environment.

The PR #52393 provides a workaround for files with `import('server-only')`, but does not allow testing pages with metadata.

*C. Always compile for node*

Our jest-transformer could always configure next -swc for server:

- pass Server-specific validations `import('server-only')`  `export const metadata` or `export async function generateMetadata`
- does not complain about `"use client"`

This is what this PR is about!

Fixes #47299

Co-authored-by: Jiachi Liu <4800338+huozhi@users.noreply.github.com>
  • Loading branch information
feugy and huozhi authored Aug 16, 2023
1 parent 61e1858 commit 77acd16
Show file tree
Hide file tree
Showing 10 changed files with 44 additions and 78 deletions.
1 change: 0 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@
"image-size": "1.0.0",
"is-docker": "2.0.0",
"is-wsl": "2.2.0",
"jest-docblock": "29.4.3",
"jest-worker": "27.0.0-next.5",
"json5": "2.2.3",
"jsonwebtoken": "9.0.0",
Expand Down
19 changes: 2 additions & 17 deletions packages/next/src/build/swc/jest-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ DEALINGS IN THE SOFTWARE.
import vm from 'vm'
import { transformSync } from './index'
import { getJestSWCOptions } from './options'
import * as docblock from 'next/dist/compiled/jest-docblock'
import type {
TransformerCreator,
TransformOptions,
Expand Down Expand Up @@ -77,30 +76,16 @@ function isEsm(
)
}

function getTestEnvironment(
src: string,
jestConfig: Config.ProjectConfig
): string {
const docblockPragmas = docblock.parse(docblock.extract(src))
const pragma = docblockPragmas['jest-environment']
const environment =
(Array.isArray(pragma) ? pragma[0] : pragma) ?? jestConfig.testEnvironment
return environment
}

const createTransformer: TransformerCreator<
SyncTransformer<JestTransformerConfig>,
JestTransformerConfig
> = (inputOptions) => ({
process(src, filename, jestOptions) {
const jestConfig = getJestConfig(jestOptions)
const testEnvironment = getTestEnvironment(src, jestConfig)

const swcTransformOpts = getJestSWCOptions({
// When target is node it's similar to the server option set in SWC.
isServer:
testEnvironment === 'node' ||
testEnvironment.includes('jest-environment-node'),
// Always target server when compiling during test, to pass server-only validations and allow testing pages with metadatas
isServer: true,
filename,
jsConfig: inputOptions?.jsConfig,
resolvedBaseUrl: inputOptions?.resolvedBaseUrl,
Expand Down
21 changes: 0 additions & 21 deletions packages/next/src/compiled/jest-docblock/LICENSE

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/src/compiled/jest-docblock/index.js

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/src/compiled/jest-docblock/package.json

This file was deleted.

2 changes: 1 addition & 1 deletion packages/next/src/compiled/sass-loader/cjs.js

Large diffs are not rendered by default.

10 changes: 0 additions & 10 deletions packages/next/taskfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -2195,15 +2195,6 @@ export async function ncc_https_proxy_agent(task, opts) {
.target('src/compiled/https-proxy-agent')
}

// eslint-disable-next-line camelcase
externals['jest-docblock'] = 'next/dist/compiled/jest-docblock'
export async function ncc_jest_docblock(task, opts) {
await task
.source(relative(__dirname, require.resolve('jest-docblock')))
.ncc({ packageName: 'jest-docblock', externals })
.target('src/compiled/jest-docblock')
}

export async function precompile(task, opts) {
await task.parallel(
[
Expand Down Expand Up @@ -2338,7 +2329,6 @@ export async function ncc(task, opts) {
'ncc_opentelemetry_api',
'ncc_http_proxy_agent',
'ncc_https_proxy_agent',
'ncc_jest_docblock',
'ncc_mini_css_extract_plugin',
],
opts
Expand Down
5 changes: 0 additions & 5 deletions packages/next/types/misc.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,3 @@ declare module 'next/dist/compiled/@opentelemetry/api' {
import * as m from '@opentelemetry/api'
export = m
}

declare module 'next/dist/compiled/jest-docblock' {
import m from 'jest-docblock'
export = m
}
15 changes: 2 additions & 13 deletions pnpm-lock.yaml

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

47 changes: 39 additions & 8 deletions test/production/jest/server-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ describe('next/jest', () => {
next = await createNext({
skipStart: true,
files: {
'app/page.jsx': `import { PI } from '../lib/util'
export default function Home() {
return <h1>{PI}</h1>
}`,
'app/layout.jsx': `export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}`,

'app/page.jsx': `import { PI } from '../lib/util'
export default function Home() {
return <h1>{PI}</h1>
}`,

'app/page.test.jsx': `import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Page from './page'
Expand All @@ -27,13 +29,42 @@ describe('next/jest', () => {
render(<Page />)
expect(screen.getByRole('heading')).toHaveTextContent('3.14')
})`,
'lib/util.js': `/** @jest-environment node */

'app/[blog]/page.jsx': `import { Metadata } from 'next'
export async function generateMetadata({
params: { blog: title },
}) {
return { title, description: 'A blog post about ' + title }
}
export default function Page({ params }) {
return <h1>All about {params.blog}</h1>
}
`,

'app/[blog]/page.test.jsx': `import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Page from './page'
describe('Blog Page', () => {
it('has the appropriate title', () => {
render(<Page params={{ blog: 'Jane' }} />)
expect(screen.getByRole('heading')).toHaveTextContent('All about Jane')
})
})
`,

'lib/util.js': `
import 'server-only'
export const PI = 3.14;`,
'lib/utils.test.ts': `import { PI } from './util'

'lib/utils.test.ts': `
import { PI } from './util'
it('works from server-side code', () => {
expect(PI).toEqual(3.14)
})`,

'jest.config.js': `module.exports = require('next/jest')({ dir: './' })({ testEnvironment: 'jsdom' })`,
},
buildCommand: `yarn jest`,
Expand All @@ -48,11 +79,11 @@ describe('next/jest', () => {

afterAll(() => next.destroy())

it('can run test against server side components', async () => {
it('can run test against server server only code', async () => {
try {
await next.start()
} finally {
expect(next.cliOutput).toInclude('Tests: 2 passed, 2 total')
expect(next.cliOutput).toInclude('Tests: 3 passed, 3 total')
}
})
})

0 comments on commit 77acd16

Please sign in to comment.