Back to blog
May 5, 2026

Why We Skipped Vitest and Went All-in on Playwright

TestingPlaywrightVitestBunE2E

When Vitest failed with ERR_REQUIRE_ESM under Bun runtime, we chose Playwright for all testing — here's why

Why We Skipped Vitest and Went All-in on Playwright

The Goal

Fable is a monorepo with two workspaces:

  • agent/ — Bun + TypeScript backend
  • ui/ — React + Vite frontend
  • We wanted to add tests. Specifically:

    1. Unit tests for React components (StoryBook.tsx, LandingPage.tsx, etc.)

    2. E2E tests for browser flows (navigate to /setup, fill form, submit, etc.)

    Simple enough, right?

    Attempt 1: Vitest

    Setup

    Vitest is the natural choice for Vite projects. We installed it:

    cd ui
    bun install -d vitest @testing-library/react @testing-library/jest-dom jsdom

    Created vite.config.ts:

    // ui/vite.config.ts
    import { defineConfig } from "vitest/config";
    import react from "@vitejs/plugin-react";
    
    export default defineConfig({
      plugins: [react()],
      test: {
        environment: "jsdom",
        setupFiles: ["./src/test/setup.ts"],
      },
    });

    Created src/test/setup.ts:

    // ui/src/test/setup.ts
    import "@testing-library/jest-dom/vitest";

    Wrote our first test:

    // ui/src/components/LandingPage.test.tsx
    import { render, screen } from "@testing-library/react";
    import LandingPage from "./LandingPage";
    
    test("renders landing page", () => {
      render(<LandingPage />);
      expect(screen.getByText("Fable")).toBeInTheDocument();
    });

    The Error

    Ran the test:

    cd ui
    bun test run src/components/LandingPage.test.tsx

    Output:

    Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/vitest/dist/index.js not supported.

    Investigation

    The issue: Bun's runtime doesn't play well with Vitest's ESM expectations.

    Vitest expects to run under Node.js, where it can control the module system. Bun uses its own runtime, which has different ESM/CJS interop behavior.

    We tried:

    1. Using `bun test` instead of `vitest` — But bun test doesn't support jsdom environment properly.

    2. Running Vitest with Node directlynode node_modules/.bin/vitest — But then it can't find Bun-specific imports in the codebase.

    3. Configuring Vitest for ESM — Added "type": "module" to package.json, various test.deps configs — Still ERR_REQUIRE_ESM.

    After 3 hours of debugging, we made a decision: skip Vitest.

    Attempt 2: Playwright for E2E

    Setup

    Playwright was already partially set up in the project. We completed the setup:

    cd ui
    bun install -D @playwright/test
    npx playwright install chromium

    Created playwright.config.ts:

    // ui/playwright.config.ts
    import { defineConfig } from "@playwright/test";
    
    export default defineConfig({
      webServer: {
        command: "bun run dev:ui",
        url: "http://localhost:3000",
        reuseExistingServer: !process.env.CI,
      },
      use: {
        baseURL: "http://localhost:3000",
      },
      projects: [
        {
          name: "chromium",
          use: { ...devices["Desktop Chrome"] },
        },
      ],
    });

    Writing Tests

    First, basic route tests:

    // ui/e2e/routes.spec.ts
    import { test, expect } from "@playwright/test";
    
    test("loads landing page", async ({ page }) => {
      await page.goto("/");
      await expect(page.locator("h1")).toHaveText("Fable");
    });
    
    test("navigates to setup page", async ({ page }) => {
      await page.goto("/");
      await page.click("text=Start Story");
      await expect(page).toHaveURL("/setup");
    });
    
    test("navigates to discover page", async ({ page }) => {
      await page.goto("/");
      await page.click("text=Discover");
      await expect(page).toHaveURL("/discover");
    });

    Then, navigation tests:

    // ui/e2e/navigation.spec.ts
    import { test, expect } from "@playwright/test";
    
    test("browser back button works", async ({ page }) => {
      await page.goto("/setup");
      await page.click("text=Back");
      await expect(page).toHaveURL("/");
    });
    
    test("deep link to story page", async ({ page }) => {
      // This tests the route-based architecture
      await page.goto("/story/abc123");
      // Should either load the story or show 404
      await expect(page.locator("body")).toBeVisible();
    });

    The Win

    Playwright tests actually ran:

    cd ui
    npx playwright test
    
    # Output:
    ✓ e2e/routes.spec.ts:3 tests passed
    ✓ e2e/navigation.spec.ts:3 tests passed
    ✓ e2e/discover.spec.ts:2 tests passed
    
    9 tests passed!

    Why Playwright Over Vitest?

    1. It Actually Works

    This is the most important reason. Playwright runs in a real browser (Chromium, Firefox, WebKit). It doesn't care about Bun vs Node ESM issues because it controls the browser directly.

    2. Better Coverage for Our Use Case

    We realized: E2E tests cover more user-facing scenarios than unit tests:

    | Test Type | What it Catches |

    | --------------------- | --------------------------------- |

    | Unit test (Vitest) | Component renders correctly |

    | E2E test (Playwright) | User can navigate through the app |

    For a user-facing app like Fable, the second one matters more.

    3. Real Browser Behavior

    Playwright tests real browser behavior:

  • Browser back button
  • Deep linking
  • Page refresh
  • localStorage persistence
  • Network requests
  • Unit tests with jsdom simulate these behaviors, but not always accurately.

    4. Already Working

    The E2E setup was already partially done. Vitest required fighting with ESM issues. The choice was clear.

    What We Lost (and Why It's OK)

    Lost: Fast Unit Tests for Components

    With Vitest, we could write:

    test("StoryBook renders messages", () => {
      render(<StoryBook messages={mockMessages} />);
      expect(screen.getByText("Once upon a time...")).toBeInTheDocument();
    });

    With Playwright, we can't easily test individual components in isolation.

    Why it's OK: Fable's components are mostly UI + API calls. The real behavior happens when the user interacts with the browser. E2E tests catch the important issues.

    Lost: Quick Feedback Loop

    Unit tests run in milliseconds. Playwright tests take seconds (browser startup, page navigation, etc.).

    Why it's OK: We run E2E tests in CI/CD. For local development, we rely on the dev server's fast refresh.

    Our Testing Strategy Now

    Playwright E2E Tests (Primary)

    // ui/e2e/routes.spec.ts
    // Tests all routes load correctly
    test("loads /setup page", async ({ page }) => {
      await page.goto("/setup");
      await expect(page.locator("h1")).toBeVisible();
    });
    
    // ui/e2e/navigation.spec.ts
    // Tests browser navigation
    test("back button from /setup goes to /", async ({ page }) => {
      await page.goto("/setup");
      await page.goBack();
      await expect(page).toHaveURL("/");
    });
    
    // ui/e2e/discover.spec.ts
    // Tests discover page functionality
    test("shows public stories", async ({ page }) => {
      await page.goto("/discover");
      await expect(page.locator(".story-card")).toHaveCount(5);
    });

    Manual Testing (Secondary)

    For edge cases that are hard to automate:

  • "The end" confirmation dialog
  • Progress polling during post-processing
  • Error states (API down, network error)
  • Future: Firebase Emulator Tests

    We might add integration tests with Firebase emulator:

    // ui/e2e/integration.spec.ts
    test("full story creation flow", async ({ page }) => {
      // Sign in (with emulator)
      await page.click("text=Sign In");
    
      // Fill setup form
      await page.fill("#age", "7");
      await page.click("text=Next");
    
      // Write story
      await page.fill("#prompt", "Once upon a time...");
      await page.click("text=Send");
    
      // Wait for completion
      await page.waitForURL("/story/**");
      await expect(page.locator(".story-title")).toBeVisible();
    });

    Lessons Learned

    1. Don't Fight Your Tools

    If a tool doesn't work after reasonable effort (3 hours in our case), switch. Don't waste days fighting ESM issues when there's a working alternative.

    2. E2E Tests Are Better for User-Facing Apps

    For apps where the user journey matters more than individual component logic:

  • E2E tests catch real user flows
  • Unit tests catch implementation details
  • Choose based on what matters for your app.

    3. Playwright > Vitest for Bun Monorepos

    | Criteria | Vitest | Playwright |

    | ---------------- | --------------- | ------------------------ |

    | Works with Bun | ❌ ESM issues | ✅ Runs in browser |

    | Tests user flows | ⚠️ Simulated | ✅ Real browser |

    | Setup effort | ❌ High (ESM) | ✅ Low (already working) |

    | Test coverage | Component logic | User journeys |

    4. It's OK to Skip "Best Practices"

    The "best practice" says: write unit tests for all components. But if the tooling doesn't support your stack, it's OK to choose a different strategy.

    The goal is tested code, not adherence to dogma.

    5. Test the Important Stuff

    We prioritized testing:

    1. All routes load correctly ✅

    2. Navigation works (back button, deep links) ✅

    3. Critical user flows (story creation) ⚠️ (in progress)

    We didn't prioritize:

  • Individual component rendering ❌ (not important for our use case)
  • Snapshot tests ❌ (too brittle with Tailwind classes)
  • Code coverage ❌ (meaningless metric if you test the wrong things)
  • The Takeaway

    Vitest is great. But if you're using Bun and hit ERR_REQUIRE_ESM, don't bang your head against the wall.

    Playwright might be the better choice for your stack anyway — real browser, real user flows, real confidence.


    _Check more open source repos at: github.com/mnkrana/_