How ViTest Transformed Our React Unit Testing Approach.

Published on July 25, 2025
https://vitest.dev/

When we started working on testing our UI application at the Unit level, we faced a common dilemma that hits many teams: How to implement a robust, fast, and developer-friendly unit testing strategy that could scale with our growing codebase, and which framework to use?

To provide some context, our application isn’t trivial. It’s a sophisticated Next.js enterprise platform featuring data visualizations, modern UI component libraries, state management, server state caching, authentication flows, feature flags, and different deployment environments.

Traditional testing setups felt heavy, slow, and often became a bottleneck rather than an enabler. We needed something different, something that would make testing a joy, not a pain on the neck.

The Research began 🔍

After researching modern testing frameworks, we discovered Vitest a very fast unit testing framework powered by Vite. What caught our attention wasn’t just the performance promises, but the seamless integration with modern tooling and the set up and testing experience it offered.

Why Vitest Over Jest

Before going down this path, let’s be clear, this isn’t about choosing one framework over another, it’s about what suits you, what works for you, and what makes your life easier.

Our Next.js application relies heavily on ES modules, and Jest’s CommonJS-first approach created constant friction. We were spending too much time configuring transformations and dealing with module resolution issues. Vitest’s native ESM support meant our test environment matched our application environment just perfectly.

Vitest leverages the exact same transformation pipeline. This means no more maintaining separate configurations for development, build, and test environments. What works in development automatically works in tests, plugins, path resolution, TypeScript configs, everything just works consistently. It provides zero-configuration TypeScript support out of the box, and uses the same TypeScript service as your editor, so type errors in tests surface immediately and consistently.

The Hot Module Replacement(HMR)

Traditional test runners restart the entire test suite on changes, which could take 10–30 seconds for small codebases. Vitest’s HMR means only affected tests rerun, usually completing in under a second. 🔥

Vitest’s architecture, built on top of Vite’s fast bundling, maintains consistent performance even with multiple test files. We noticed a 5–6x test execution time improvement. 🚀

Lastly, Vitest supports modern JavaScript features without additional configuration. Top-level await, dynamic imports, worker threads, everything works as expected without wrestling with babel configs or polyfills.

Lets talk about the Implementation Journey

Our first step was creating a Vitest configuration that could handle our Next.js setup:


///
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'

export default defineConfig({
esbuild: {
jsx: 'automatic',
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: [
'node_modules',
'dist',
'.next',
'coverage',
],
reporters: ['default', 'junit'],
outputFile: {
junit: './coverage/junit.xml'
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov', 'cobertura'],
exclude: [
'node_modules/',
'**/*.config.{ts,js}',
'dist/',
'.next/',
],
// Configure V8 coverage
reportsDirectory: './coverage',
skipFull: false,
all: true,
// Only include source files you actually want to track
include: [
'src/**/*.{ts,tsx}',
'!src/test/**',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
'!src/**/*.stories.{ts,tsx}',
]
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
define: {
global: 'globalThis',
},
})

The key insight here was configuring ESBuild to use automatic JSX transformation, which eliminated countless import issues we faced with traditional setups.

Test Environment Setup

We created a test setup file:

// src/test/setup.ts
import '@testing-library/jest-dom'
import { vi } from 'vitest'

// Mock Next.js router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/test-path',
}))

// Mock next-runtime-env
vi.mock('next-runtime-env', () => ({
env: vi.fn(() => 'test-value'),
}))

// Global test utilities
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))

Smart Test Utilities

We built a test utilities module that eliminates boilerplate:


// src/test/utils.tsx
import React, { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { ThemeProvider } from '@mui/material/styles'
import { setupStore } from '@/store/store'

interface TestProvidersProps {
children: React.ReactNode
initialState?: any
}

const TestProviders = ({ children, initialState }: TestProvidersProps) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})

const store = setupStore(initialState)

return (



{children}



)
}

const customRender = (
ui: ReactElement,
options?: RenderOptions & { initialState?: any }
) => {
const { initialState, ...renderOptions } = options || {}

const wrapper = ({ children }: { children: React.ReactNode }) => (

{children}

)

return render(ui, { wrapper, ...renderOptions })
}

export * from '@testing-library/react'
export { customRender as render }

This utility became our secret weapon, as it automatically wraps components with all necessary providers, eliminating hundreds of lines of repetitive setup code.

Testing Components

Now, let’s look at how we test our components. Some of these are sophisticated pieces (in the real project) that handle data fetching, state management, and complex UI interactions:


// src/components/DataDisplay/DataDisplay.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { render } from '@/test/utils'
import { DataDisplay } from './DataDisplay'
import { useDataFetch } from '@/hooks/useDataFetch'

vi.mock('@/hooks/useDataFetch', () => ({
useDataFetch: vi.fn(),
}))

describe('DataDisplay', () => {
const mockUseDataFetch = vi.mocked(useDataFetch)

beforeEach(() => {
vi.clearAllMocks()
})

describe('Basic Rendering', () => {
it('renders the component successfully with data', async () => {
// Arrange
mockUseDataFetch.mockReturnValue({
data: mockData,
isLoading: false,
error: null,
})

// Act
render()

// Assert
await waitFor(() => {
expect(screen.getByText('Data Overview')).toBeInTheDocument()
expect(screen.getByText('Status: Active')).toBeInTheDocument()
})
})

it('displays loading state when data is loading', () => {
// Arrange
mockUseDataFetch.mockReturnValue({
data: null,
isLoading: true,
error: null,
})

// Act
render()

// Assert
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument()
})
})
})

Testing Hooks and Utilities

Vitest made testing custom hooks incredibly straightforward:


// src/hooks/useClipboard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useClipboard } from './useClipboard'

Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockImplementation(() => Promise.resolve()),
},
})

describe('useClipboard', () => {
it('copies text to clipboard successfully', async () => {
const { result } = renderHook(() => useClipboard())

await act(async () => {
await result.current.copyToClipboard('Hello, World!')
})

expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Hello, World!')
expect(result.current.isCopied).toBe(true)
})
})

Results

The impact was immediate and dramatic! Our full unit test suite execution time takes nearly 8 seconds 🤯 Developers can see test results before their mental context switched away from the code they were writing.

Coverage

With Vitest’s built-in V8 coverage provider, you can generate .XML, HTML, .JSON artifacts to download, browse and view after each execution

% Coverage report from v8
% Files | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
% All files | 21.85 | 4372 | 1151 | 1118 | 4372
% components | 18.73 | 3890 | 981 | 989 | 3890
% hooks | 4.27 | 82 | 18 | 19 | 82
% utils | 22.06 | 400 | 152 | 110 | 400

The Final Piece

We integrated our Vitest setup into GitLab CI, creating a robust but simple quality gate:


# .gitlab-ci.yml
unit-tests:
stage: test
image: node:18-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
- .npm/
before_script:
- npm ci --cache .npm --prefer-offline
script:
- npm run test:coverage
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
artifacts:
when: always
expire_in: 30 days
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
junit: coverage/junit.xml
paths:
- coverage/
# Enable HTML artifact browsing - GitLab detects index.html automatically
expose_as: 'Coverage Report'
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG

This setup ensures that every merge request is tested, coverage reports are generated and tracked, no code reaches production without passing tests, and multiple coverage visualization methods / artifacts are available for the teams review.

Embracing Modern Frameworks

Our journey with Vitest represents more than just a testing framework approach, it shows the transformative power of embracing modern tooling in software development.

For some teams, traditional testing often feels like an traffic jam, a manual delay, a necessary evil that slows down development. There are certainly many ways to change this perception at many levels, Vitest is just one of them. In this scenario, tests became documentation, clear, readable tests that explain component behavior, but most importantly, that provide speed of light feedback, and eliminate fear of breaking changes.

This is a wrap! Leaving you with quote from a SDET that I know,

“Let’s make our lives easier.”