Introduction to Testing Turbopack Components
Turbopack is the next-generation bundler built by Vercel, designed as a high-performance successor to Webpack. Written in Rust, it promises dramatically faster builds and incremental compilation. However, with great speed comes the responsibility of ensuring correctness. Testing Turbopack components — whether they are custom plugins, loaders, transformers, or full applications bundled by Turbopack — is essential to maintain reliability and developer confidence.
This tutorial walks you through the full testing pyramid for Turbopack-based projects: from isolated unit tests for individual modules, through integration tests that verify how components interact, all the way to end-to-end (E2E) tests that validate the final bundled output in a real browser environment.
Why Testing Turbopack Components Matters
While Turbopack handles much of the heavy lifting internally, developers still build components and configurations around it. Custom loaders, module federation setups, dynamic imports, and environment-specific configurations all introduce surface area for bugs. Without a robust testing strategy, you risk shipping broken bundles, misconfigured asset pipelines, or runtime errors that only surface in production.
Key reasons to invest in testing include:
- Regression protection: Turbopack is evolving rapidly. Updates can introduce subtle behavioral changes that break your pipeline.
- Configuration confidence: Complex
turbo.jsonandnext.config.jssetups benefit from automated verification. - Performance validation: Tests can assert that bundles remain within expected size and compilation time thresholds.
- Team collaboration: Tests serve as living documentation for how your build pipeline is expected to behave.
Understanding the Testing Pyramid for Turbopack
Before diving into code, it helps to understand where each type of test fits. The classic testing pyramid applies here:
- Unit tests — Test individual functions, utilities, or small modules in isolation. Fast and numerous.
- Integration tests — Verify that multiple modules work together, including interactions with Turbopack's compilation pipeline.
- E2E tests — Run the full application in a browser, validating that the bundled output behaves correctly for real users.
Setting Up Your Testing Environment
For this tutorial, we will use a Next.js project with Turbopack enabled, along with Vitest for unit and integration testing, and Playwright for E2E testing. Start by creating a project and installing dependencies:
npx create-next-app@latest my-turbo-app
cd my-turbo-app
npm install --save-dev vitest @vitest/ui jsdom @testing-library/react
npm install --save-dev @playwright/test
npx playwright install
Enable Turbopack in your development and build scripts by updating package.json:
{
"scripts": {
"dev": "next dev --turbo",
"build": "next build --turbo",
"test": "vitest",
"test:e2e": "playwright test"
}
}
Create a vitest.config.ts file at the project root:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Create the setup file at tests/setup.ts:
import '@testing-library/jest-dom/vitest';
Writing Unit Tests for Individual Components
Unit tests focus on the smallest testable parts of your application. Let's say you have a utility module that formats data for display. Here is the source file at src/lib/formatters.ts:
export function formatPrice(cents: number, currency = 'USD'): string {
if (typeof cents !== 'number' || isNaN(cents)) {
throw new Error('formatPrice expects a valid number');
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(cents / 100);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
Now create a unit test at src/lib/formatters.test.ts:
import { describe, it, expect } from 'vitest';
import { formatPrice, slugify } from './formatters';
describe('formatPrice', () => {
it('formats cents into a currency string', () => {
expect(formatPrice(1099)).toBe('$10.99');
expect(formatPrice(0)).toBe('$0.00');
});
it('supports different currencies', () => {
expect(formatPrice(500, 'EUR')).toBe('€5.00');
});
it('throws on invalid input', () => {
expect(() => formatPrice(NaN)).toThrow('valid number');
expect(() => formatPrice('100' as unknown as number)).toThrow();
});
});
describe('slugify', () => {
it('converts text to URL-friendly slugs', () => {
expect(slugify('Hello World!')).toBe('hello-world');
expect(slugify(' Multiple Spaces ')).toBe('multiple-spaces');
});
it('removes special characters', () => {
expect(slugify('Café & Muffins')).toBe('caf-muffins');
});
});
Run the tests with npm test. These tests execute in milliseconds and give you immediate feedback on the correctness of your utility logic.
Testing React Components in Isolation
Consider a ProductCard component at src/components/ProductCard.tsx:
import { formatPrice } from '@/lib/formatters';
interface ProductCardProps {
name: string;
priceCents: number;
onAddToCart?: () => void;
}
export function ProductCard({ name, priceCents, onAddToCart }: ProductCardProps) {
return (
<article data-testid="product-card">
<h3>{name}</h3>
<p data-testid="price">{formatPrice(priceCents)}</p>
<button onClick={onAddToCart} data-testid="add-to-cart">
Add to Cart
</button>
</article>
);
}
The corresponding test at src/components/ProductCard.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProductCard } from './ProductCard';
describe('ProductCard', () => {
it('renders product name and formatted price', () => {
render(<ProductCard name="Wireless Mouse" priceCents={2499} />);
expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
expect(screen.getByTestId('price')).toHaveTextContent('$24.99');
});
it('calls onAddToCart when the button is clicked', () => {
const handleAdd = vi.fn();
render(<ProductCard name="Keyboard" priceCents={8900} onAddToCart={handleAdd} />);
fireEvent.click(screen.getByTestId('add-to-cart'));
expect(handleAdd).toHaveBeenCalledTimes(1);
});
});
Writing Integration Tests for Turbopack-Bundled Modules
Integration tests verify that multiple pieces of your application work together correctly. When using Turbopack, a common integration concern is whether modules that rely on dynamic imports, code splitting, or custom aliases resolve correctly after bundling.
One effective approach is to use Turbopack's programmatic API or the Next.js build output to verify that your modules compile and interact as expected. Here is an integration test that checks a data-fetching hook works with a mocked API:
// src/hooks/useProduct.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useProduct } from './useProduct';
describe('useProduct (integration)', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fetches and returns product data', async () => {
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Headphones', priceCents: 12999 }),
});
const { result } = renderHook(() => useProduct(1));
await waitFor(() => expect(result.current.product).not.toBeNull());
expect(result.current.product?.name).toBe('Headphones');
expect(result.current.error).toBeNull();
});
it('handles fetch errors gracefully', async () => {
(global.fetch as any).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useProduct(2));
await waitFor(() => expect(result.current.error).not.toBeNull());
expect(result.current.error?.message).toBe('Network error');
expect(result.current.product).toBeNull();
});
});
Testing Dynamic Imports and Code Splitting
Turbopack handles dynamic imports differently from Webpack in some edge cases. You can write integration tests that verify lazy-loaded components render correctly. Here is an example using next/dynamic:
// src/components/LazyChart.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import dynamic from 'next/dynamic';
const LazyChart = dynamic(() => import('./Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});
describe('LazyChart integration', () => {
it('shows a loading state then renders the chart', async () => {
render(<LazyChart data={[1, 2, 3]} />);
expect(screen.getByText('Loading chart...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('chart')).toBeInTheDocument();
});
});
});
Writing End-to-End Tests with Playwright
E2E tests validate the full user experience by running your application in a real browser. This is where you confirm that Turbopack's bundled output actually works end to end. Create a Playwright configuration file at playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Create your first E2E test at e2e/home.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Home page', () => {
test('displays the hero section and navigation', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
await expect(page.getByRole('link', { name: /products/i })).toBeVisible();
});
test('navigates to the products page', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: /products/i }).click();
await expect(page).toHaveURL(/\/products/);
await expect(page.getByTestId('product-list')).toBeVisible();
});
});
Testing User Flows with Turbopack-Bundled Pages
Let's write a more comprehensive E2E test that exercises a shopping cart flow, which depends on multiple bundled modules working together:
// e2e/cart.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Shopping cart flow', () => {
test('user can add a product and view the cart', async ({ page }) => {
await page.goto('/products');
// Wait for product cards to load (dynamic import)
await expect(page.getByTestId('product-card').first()).toBeVisible();
// Add the first product to cart
const firstCard = page.getByTestId('product-card').first();
const productName = await firstCard.getByRole('heading').textContent();
await firstCard.getByTestId('add-to-cart').click();
// Navigate to cart
await page.getByTestId('cart-link').click();
await expect(page).toHaveURL(/\/cart/);
// Verify the product appears in the cart
await expect(page.getByTestId('cart-item')).toHaveCount(1);
await expect(page.getByTestId('cart-item')).toContainText(productName!);
});
test('cart total updates correctly', async ({ page }) => {
await page.goto('/products');
const cards = page.getByTestId('product-card');
const count = await cards.count();
expect(count).toBeGreaterThan(0);
// Add two products
await cards.nth(0).getByTestId('add-to-cart').click();
await cards.nth(1).getByTestId('add-to-cart').click();
await page.getByTestId('cart-link').click();
const totalText = await page.getByTestId('cart-total').textContent();
const total = parseFloat(totalText!.replace(/[^0-9.]/g, ''));
expect(total).toBeGreaterThan(0);
});
});
Testing Turbopack Configuration and Build Output
Beyond application code, you should test that your Turbopack configuration produces the expected output. This is especially important for monorepos or projects with custom webpack/turbopack rules. Here is a test that verifies the build completes without errors and produces expected assets:
// tests/build.test.ts
import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
describe('Turbopack build output', () => {
it('completes the build without errors', () => {
expect(() => {
execSync('npm run build', { stdio: 'pipe', timeout: 120_000 });
}).not.toThrow();
});
it('generates the .next directory with expected files', () => {
const nextDir = path.resolve(process.cwd(), '.next');
expect(fs.existsSync(nextDir)).toBe(true);
expect(fs.existsSync(path.join(nextDir, 'BUILD_ID'))).toBe(true);
});
it('produces static HTML for the home page', () => {
const serverDir = path.resolve(process.cwd(), '.next/server/app');
const htmlFiles = fs.existsSync(serverDir)
? fs.readdirSync(serverDir).filter((f) => f.endsWith('.html'))
: [];
expect(htmlFiles.length).toBeGreaterThan(0);
});
});
Best Practices for Testing Turbopack Components
1. Keep Unit Tests Fast and Focused
Unit tests should run in under 100 milliseconds each. Avoid network calls, file system access, or heavy computations. Mock external dependencies aggressively. Vitest's built-in mocking utilities make this straightforward:
import { vi } from 'vitest';
vi.mock('@/lib/api', () => ({
fetchProducts: vi.fn().mockResolvedValue([
{ id: 1, name: 'Test Product', priceCents: 1000 },
]),
}));
2. Test Behavior, Not Implementation
Focus on what your components do, not how they do it. This makes tests more resilient to refactoring. Prefer querying by role, label, or test ID rather than by CSS class or internal state:
// Good: testing behavior
expect(screen.getByRole('button', { name: /submit/i })).toBeEnabled();
// Avoid: testing implementation details
expect(wrapper.find('.btn-primary').hasClass('disabled')).toBe(false);
3. Use Snapshot Testing Sparingly
Snapshots can catch unexpected changes, but they are easy to update blindly. Use them for stable output like serialized configuration or rendered markup of static components, not for frequently changing UI:
it('matches snapshot for static header', () => {
const { container } = render(<Header title="My Store" />);
expect(container.firstChild).toMatchSnapshot();
});
4. Parallelize E2E Tests
E2E tests are slow. Playwright runs tests in parallel by default across browser contexts. Keep tests independent so they can run in any order. Avoid sharing state between tests:
// Good: each test starts fresh
test('can search for products', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder(/search/i).fill('headphones');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByTestId('product-card')).toHaveCount(1);
});
5. Assert on Bundle Size and Performance
Turbopack's speed is a key benefit, so include performance assertions in your test suite. You can use a custom script to check bundle sizes after a build:
// tests/performance.test.ts
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import glob from 'glob';
describe('bundle size limits', () => {
it('keeps JavaScript bundles under 250KB gzipped', () => {
const staticDir = path.resolve(process.cwd(), '.next/static/chunks');
if (!fs.existsSync(staticDir)) return; // skip if not built
const files = glob.sync('**/*.js', { cwd: staticDir });
let totalSize = 0;
for (const file of files) {
const stats = fs.statSync(path.join(staticDir, file));
totalSize += stats.size;
}
// Approximate gzip ratio is ~30% of raw size
const estimatedGzipSize = totalSize * 0.3;
expect(estimatedGzipSize).toBeLessThan(250 * 1024);
});
});
6. Set Up CI Pipelines with Caching
In CI, cache your dependencies and Playwright browsers to speed up test runs. Here is a GitHub Actions example:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm test
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
7. Test Edge Cases and Error Boundaries
Turbopack's tree-shaking and code splitting can sometimes surface unexpected behavior at the edges. Write tests for error boundaries, empty states, and loading states:
it('renders error boundary when a child throws', () => {
const ThrowComponent = () => {
throw new Error('Test error');
};
render(
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<ThrowComponent />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
Debugging Failing Tests
When tests fail, use these strategies to debug efficiently:
- Run Vitest in UI mode with
npx vitest --uifor an interactive test explorer. - Use Playwright's debug mode with
npx playwright test --debugto step through tests with Inspector. - Take screenshots and capture traces in Playwright for post-failure analysis.
- Log Turbopack compilation output by setting
DEBUG=turbo*in your environment.
Here is how to capture a trace on failure in Playwright:
test('debugging example', async ({ page }) => {
await page.goto('/');
await page.screenshot({ path: 'screenshots/home.png' });
// If this test fails, the trace will be available
});
Conclusion
Testing Turbopack components across the full spectrum — from fast unit tests that validate individual functions, through integration tests that confirm modules cooperate correctly, to comprehensive E2E tests that verify the real user experience — gives you the confidence to ship reliably at speed. By combining Vitest for unit and integration coverage with Playwright for browser-level validation, and by layering in build-output and performance assertions, you create a safety net that catches regressions early and documents expected behavior for your entire team. As Turbopack continues to evolve, a well-structured test suite will be your best insurance against subtle breaking changes, ensuring that the performance gains of this modern bundler never come at the cost of correctness.