← Back to DevBytes

Testing Turbopack Components: From Unit to E2E Tests

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:

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:

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles