DEV Community

Happy
Happy

Posted on

Vite + React + Vitest: a simple test setup you can copy in 10 minutes

If you use Vite + React + TypeScript, the fastest way to add tests is Vitest.

In this post, I will show a clean setup you can copy.

Why this stack?

  • Vite: fast dev server
  • Vitest: test runner that feels like Jest, but faster in Vite projects
  • React Testing Library: test from user perspective

This setup works well with pnpm and small-to-medium frontend apps.


1) Install packages

pnpm add -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Enter fullscreen mode Exit fullscreen mode

2) Update vite.config.ts

/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    globals: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

What this does:

  • jsdom gives a browser-like environment
  • setupFiles runs once before tests
  • globals: true lets you use describe, it, expect without importing every time

3) Create src/test/setup.ts

import '@testing-library/jest-dom'
Enter fullscreen mode Exit fullscreen mode

This adds helpers like toBeInTheDocument().


4) Add scripts to package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui"
  }
}
Enter fullscreen mode Exit fullscreen mode

Use:

  • pnpm test for CI
  • pnpm test:watch while coding

5) Example component test

src/components/Counter.tsx

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

src/components/Counter.test.tsx

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

describe('Counter', () => {
  it('increments count when button is clicked', async () => {
    render(<Counter />)

    await userEvent.click(screen.getByRole('button', { name: /increment/i }))

    expect(screen.getByText('Count: 1')).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

This test checks real behavior, not internal state details.


Common beginner mistakes

  1. Using node environment for React tests (use jsdom)
  2. Forgetting @testing-library/jest-dom setup
  3. Testing implementation details instead of visible UI behavior

Final tip

Start with one test per component for the core user action.
You do not need 100 tests on day one.
Small, stable tests are better than many fragile tests.

If you want, next step is adding coverage and running Vitest in CI.

Top comments (1)

Collapse
 
maxxmini profile image
MaxxMini

Clean setup — almost identical to what I landed on for a finance planning tool built with Vite + React + TS.

One thing that saved me a lot of grief: adding css.modules.localsConvention to the Vitest config so CSS Module classnames resolve the same way in tests as in dev. Without it, component snapshots break silently when you use camelCase imports but your .module.css uses kebab-case. Small thing, but it cost me an afternoon the first time.

The "one test per component for core user action" advice is spot on. I started with that philosophy and now have 900+ tests — but the first 50 were the ones that actually caught real bugs. The rest mostly catch regressions during refactors, which is valuable but different.

Curious about one thing: do you run vitest run in CI with --reporter=verbose or stick with the default? I found that verbose mode in GitHub Actions logs makes it much easier to debug flaky tests remotely, but it does make the output noisy for large suites. Any sweet spot you have found?

Also the "test behavior not implementation" point deserves emphasis — I had tests checking internal useState values early on, and every small refactor broke them. Switching to screen.getByText / getByRole made the suite 10x more stable.