Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(47299): allow testing pages with metadata in jsdom test environme…
…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