Run end-to-end tests against next build output, not next dev. Development disables prefetching, recompiles on demand and skips the prerender pass, so a dev-server suite can pass while the build that ships is broken. Playwright 26,280 's webServer block builds and boots:
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:3100', trace: 'on-first-retry' },
webServer: {
command: 'npm run build && npm run start -- --port 3100',
url: 'http://localhost:3100',
env: { PRODUCTS_API: 'http://localhost:3100' },
timeout: 180_000,
},
});A non-default port keeps the suite clear of whatever next dev is holding, env points the app at a stub API, and specs live in e2e/ so neither runner collects the other's files. Add reuseExistingServer: !process.env.CI while you iterate locally.
test('the product list loads after the shell', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Products' }).click();
await expect(page).toHaveURL('/products');
await expect(page.getByText('Enamel mug — $12.00')).toBeVisible();
});$ npx playwright test ok 1 [chromium] › e2e\products.spec.ts:3:5 › the product list loads after the shell (1.2s) 1 passed (31.0s)
Thirty of those thirty-one seconds were the build. That is the trade: the only run in which proxy.ts executes, caching behaves as it will in production, the RSC payload is streamed and the client hydrates — everything Testing Server Code could not reach.
The queries are Testing Library 221,825 's, and Playwright's expect retries each until the timeout expires, which makes assertions against streamed content reliable without a single waitForTimeout. Keep the suite about flows: sign in, add to cart, check out, one authorization failure.