Separating Unit & E2E Test Layers with Vitest + Playwright — From Setup to CI Integration
There's a moment every project eventually hits: "Are the tests slow, are they wrong, or is it both?" You write unit tests with Jest, run E2E with Cypress, end up with three config files, CI pushing past 20 minutes, and before long the whole team stops touching the tests. I've been there — and the specific moment I decided "something is wrong" was the afternoon CI crossed 28 minutes. We had written a ton of E2E tests where unit tests belonged, every config file had its own Babel transpile setup, and selectors were CSS-class-based so refactoring broke them in a cascade.
This article targets Vite-based React projects. Next.js App Router and Webpack-based environments require extra setup in certain areas — those are called out separately in the trade-offs table. The core idea is not to use two tools side by side, but to clearly separate test layers and assign the right tool to each layer. By the end, you'll be able to cleanly separate unit, component, and E2E layers with just two config files and wire everything into a CI pipeline in one continuous flow.
Core Concepts
Vitest — A Test Runner Born in the Vite Ecosystem
Vitest is a testing framework for Vite-based projects that shares the Vite config out of the box with no extra setup. It provides a Jest-compatible API (describe, it, expect, vi.fn()), so you can port most existing Jest test code with minimal changes.
The speed difference is genuinely noticeable. Benchmarks show it's 5–28× faster than Jest in watch mode — the first time you try it, the results appear so fast it feels like something must be missing. I double-checked myself the first time, wondering if tests were being skipped. As of early 2026: 14 million weekly downloads, 96% satisfaction in State of JS 2025, ranking #1 among testing tools.
ESM (ECMAScript Modules): The way browsers and Node.js natively process
import/exportsyntax. Jest is CommonJS (require)-based, so using ESM code required a Babel transform. Vitest handles native ESM directly. Eliminating even one Babel config file meaningfully reduces maintenance overhead.
Playwright — The Current Standard for E2E Testing
An E2E testing tool developed by Microsoft that supports all three engines: Chromium, Firefox, and WebKit. Because it communicates directly with the browser, behavior is stable, and thanks to auto-wait you can test async UI naturally without workarounds like setTimeout. Auto-wait is the feature you feel most in practice — you don't have to write "wait until the button appears" in your code; Playwright handles it automatically.
It's often compared to Cypress, with two decisive differences: free parallel execution (Cypress requires a paid plan) and multi-tab/popup support. Satisfaction scores also show a gap: Playwright 91% vs. Cypress 74%.
Layer Separation Is the Point
Using both tools together isn't about writing more tests — it's about properly following the test pyramid. A common real-world distribution is roughly 70% unit, 20% integration, and 10% E2E.
| Layer | Tool | Target | Speed |
|---|---|---|---|
| Unit & integration tests | Vitest | Utilities, hooks, components, business logic | Very fast (ms) |
| Component browser tests | Vitest Browser Mode | Component rendering in a real browser | Fast |
| E2E tests | Playwright | User flows across pages, cross-browser | Slow (seconds) |
Vitest Browser Mode: Promoted to stable in Vitest 4.0 (released December 2025), this feature uses Playwright as a browser automation provider to run component tests inside a real Chromium instance. It's useful for verifying CSS layout, real event behavior, and other things jsdom can't simulate. It's worth checking whether your project uses Vitest 4.x or higher first.
In summary: Vitest owns the fast feedback loop, and Playwright owns the final verification of real user flows. Once the two layers have clearly divided responsibilities, the entire CI pipeline becomes much more predictable.
Practical Application
Installation and Configuration
Start by installing dependencies. I usually separate them by role so it's easy to see what each package brings in.
# Vitest + browser mode + React Testing Library
pnpm add -D vitest @vitest/browser playwright
pnpm add -D @testing-library/react @testing-library/user-event
# Playwright E2E
pnpm add -D @playwright/test
# Install browser binaries (Playwright)
pnpm exec playwright install --with-depsInstalling @vitest/browser lets you use the playwright provider alongside it. Note that there is no separate package called @vitest/browser-playwright, so watch out for that during installation.
Next, create two config files at the root.
// vitest.config.ts
/// <reference types="@vitest/browser/providers/playwright" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
// Only src/** is handled by Vitest — e2e/** is Playwright-only
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['e2e/**'],
browser: {
enabled: false, // set to true to run component tests in a real browser
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
},
},
})// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})Here are the key settings to understand:
| Setting | Meaning |
|---|---|
exclude: ['e2e/**'] |
Prevents Vitest from picking up Playwright E2E files |
retries: process.env.CI ? 2 : 0 |
Retries only in CI; failures are immediately visible locally |
reuseExistingServer: !process.env.CI |
Reuses a running dev server locally if one exists |
trace: 'on-first-retry' |
Collects traces only on first retry to save disk space |
Writing Tests by Layer
The pattern: use Vitest for fast unit tests, and Playwright only for critical flows. I copy these three file structures as a template into every new project.
// src/utils/price.test.ts — Vitest unit test
import { describe, it, expect } from 'vitest'
import { formatPrice } from './price'
describe('formatPrice', () => {
it('applies thousand-separator commas', () => {
expect(formatPrice(10000)).toBe('10,000원')
})
it('returns 0원 as-is', () => {
expect(formatPrice(0)).toBe('0원')
})
})// src/components/CartButton.test.tsx — Vitest component test
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CartButton } from './CartButton'
it('adds an item to the cart on click', async () => {
const onAdd = vi.fn()
render(<CartButton onAdd={onAdd} />)
await userEvent.click(screen.getByRole('button', { name: '장바구니 추가' }))
expect(onAdd).toHaveBeenCalledOnce()
})vi must be explicitly imported from vitest. You can omit it with globals: true, but I prefer making the import source explicit.
// e2e/checkout.spec.ts — Playwright E2E test
import { test, expect } from '@playwright/test'
test('full checkout flow validation', async ({ page }) => {
await page.goto('/products/1')
await page.getByRole('button', { name: '장바구니 추가' }).click()
await page.goto('/cart')
await page.getByRole('button', { name: '결제하기' }).click()
await expect(page).toHaveURL('/checkout')
})There's a reason to use getByRole in Playwright tests. CSS class or XPath selectors break in a cascade with every UI refactor, but ARIA role-based selectors work based on how a real user perceives the element, making them far more stable. When our team replaced a button component, not a single getByRole-based test broke.
One more thing — when E2E testing flows that require authentication, you don't need to repeat login for every test. Playwright's storageState lets you save an auth session once and reuse it. This is briefly covered in the storageState entry in the trade-offs table.
package.json Scripts and CI Integration
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "pnpm test:coverage && pnpm test:e2e"
}
}Below is an excerpt of the core steps for GitHub Actions. Add checkout, pnpm install, and cache setup for your project environment before these steps.
# .github/workflows/test.yml (core steps excerpt)
- name: Run Unit Tests
run: pnpm test:coverage
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run E2E Tests
run: pnpm test:e2eIt's recommended to keep playwright install --with-deps as a separate step. Managing browser binary caches and node_modules caches independently helps reduce pipeline execution time.
Trade-off Analysis
Advantages
| Item | Details |
|---|---|
| Vitest speed | 5–28× faster than Jest in watch mode; immediate feedback after code changes |
| Simplified config | Shares Vite config directly; no separate Babel setup needed |
| Jest migration | API compatibility means most existing Jest tests port over as-is |
| Playwright parallel execution | Multi-worker support for free; reduces CI time |
| Cross-browser coverage | Chromium, Firefox, and WebKit verified from a single config |
| Auto-wait | Stable async UI handling with no manual wait code needed |
| Auth session reuse | storageState eliminates repeated logins; improves E2E speed |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| E2E execution speed | 10–50× slower than unit tests | Limit to 20–30 critical flows; cover the rest with Vitest |
| Browser binaries | Increased CI disk usage (~300MB) | Actively use GitHub Actions cache |
| Non-Vite environments | Some Next.js App Router setups and Webpack-based legacy projects require extra config | Specify environments separately in vitest.config.ts |
| E2E maintenance cost | Excessive E2E tests cause rapidly escalating maintenance overhead | Follow the test pyramid; focus only on business-critical flows |
| Flaky tests | Intermittent failures degrade CI reliability | Absorb with retries: 2 + stabilizing selectors |
Flaky tests: Tests that sometimes pass and sometimes fail without any code change. They usually stem from timing issues or unstable selectors, and are the primary culprit that erodes the entire team's trust in CI.
Three Common Mistakes
-
Writing E2E tests like unit tests. Validating individual utility functions with Playwright pushes CI past 30 minutes. Our team repeated this pattern early on and E2E crossed 40 minutes in CI. The key is covering only the flows that directly impact the business (checkout, signup, core CRUD) with E2E, and pushing everything else up to Vitest.
-
Using CSS classes or XPath for Playwright selectors. Selectors like
div.checkout-btnbreak all your E2E tests every time you refactor a UI component. Writing withgetByRole,getByText, ordata-testidmakes tests far more resilient to structural changes. -
Mixing Vitest and Playwright test files without separation. Without proper
include/excludeconfiguration, Vitest will try to claim Playwright files as its own tests, producing strange errors. It's strongly recommended to separate by directory from the start:src/**for Vitest,e2e/**for Playwright.
Closing Thoughts
The essence of the Vitest + Playwright combination is not using more tools, but placing the right tool at each layer to achieve both "fast feedback" and "trustworthy final verification" simultaneously. Once this structure is in place, instead of team members feeling anxious when opening a PR — "will CI even pass?" — they can reason from predictable numbers like "unit tests: 30 seconds, E2E: 5 minutes."
Three steps you can take right now:
-
Try introducing Vitest into your existing project first. Install
pnpm add -D vitest, create avitest.config.tsin the same directory as yourvite.config.ts. If you have Jest-based tests, replacingimport { describe, it, expect } from 'vitest'is usually all it takes to get them running. -
Start by listing the flows you want E2E coverage for. Identify 20–30 user flows where "if this breaks, it's a real incident" and sketch out spec files under an
e2e/directory — that's half the work of adopting Playwright done. -
Wire things into the CI pipeline in order. The most common structure is two steps: run Vitest coverage first with
pnpm test:coverage, then on success runplaywright install --with-depsfollowed bypnpm test:e2e. Start with just chromium enabled, then incrementally add firefox and webkit once things are stable.
References
- Vitest Official Docs | vitest.dev — The first place to open when you want to scan all config options quickly
- Vitest Browser Mode Guide | vitest.dev — The essential page for Browser Mode setup; provider options are documented in detail
- Playwright Official Docs | playwright.dev — Especially well-organized API reference; useful for looking up selector syntax
- Playwright Best Practices | playwright.dev — Official recommended patterns compressed into one page, including the
getByRole-first principle - Why Playwright + Vitest is the Future of Web Testing | DEV Community — Helpful for understanding the context behind why these two tools pair naturally
- Vitest vs Playwright | BrowserStack — A table-based comparison of each tool's role; useful as a reference when introducing the tools to a team
- Vitest Browser Mode vs Playwright | Epic Web Dev — Worth reading when you're unsure whether to test a component with Browser Mode or E2E
- Vitest 4 Browser Mode Stable Release | InfoQ — Summary of the Vitest 4.0 release; good for quickly understanding what changed
- React Component Testing with Vitest Browser Mode and Playwright | akoskm.com — A real-world React project walkthrough with concrete configuration examples
- Configure Vitest, MSW and Playwright in a React+Vite+TS Project | DEV Community — Reference for seeing the full setup including MSW integration
- 15 Best Practices for Playwright Testing 2026 | BrowserStack — A well-organized reference covering practical Playwright patterns from selector strategy to CI optimization