Vitest with TypeScript: Best Practices and Examples

Testing modern JavaScript applications has improved a lot, but getting TypeScript and test runners to work together smoothly still needs attention to detail. With Vitest being built for speed and TypeScript providing strong typing, combining both can offer confidence and clarity. However, without following good practices, even this great setup can quickly become messy and unreliable. The real challenge is creating a testing environment that is easy to maintain, consistent, and capable of catching bugs before they ship.

Use Type-Safe Imports

One of the biggest advantages of TypeScript is early error detection through types. In Vitest, it’s important to explicitly import test functions like describe, it, and expect instead of relying on global exposure. Even though setting globals: true in your config works, it creates hidden dependencies.

Instead of this:

describe('Sample Test', () => {
  it('works', () => {
    expect(true).toBe(true)
  })
})

Prefer this:

import { describe, it, expect } from 'vitest'

describe('Sample Test', () => {
  it('works', () => {
    expect(true).toBe(true)
  })
})

It may feel repetitive, but it’s cleaner, more explicit, and helps editors like VS Code provide better IntelliSense.

Keep Tests Close

To improve readability and reduce navigation overhead, keep test files close to the code they test. For example, if you have a Button.tsx file, place the test in the same folder as Button.test.tsx.

This directory structure keeps the context clear:

FileDescription
src/components/Button.tsxThe actual button component
src/components/Button.test.tsxTests for the button

This structure avoids confusion when jumping between code and tests, especially in large codebases.

Avoid Over-Mocking

Mocking is useful but easily overused. If everything is mocked, tests lose meaning and can’t catch real issues. Only mock things you can’t control—like API responses, local storage, or browser-specific features.

Here’s a good balance:

  • Mock: fetch, localStorage, timers, third-party services.
  • Don’t mock: Your own React components or business logic.

In a component that fetches data, mocking just the network layer helps isolate its behavior without faking the entire component tree.

Use Setup Files Wisely

If your tests need shared setup, such as extending matchers with jest-dom or resetting global objects, use a setupTests.ts file. Keep it minimal and avoid putting unrelated logic here.

Example content:

import '@testing-library/jest-dom'

This enhances your test assertions without adding clutter to each test file.

Make sure your vite.config.ts includes this:

test: {
  setupFiles: './src/setupTests.ts'
}

Prefer screen Over Direct Queries

When testing with React Testing Library, screen helps keep tests clean. Instead of destructuring returned values from render, use screen.getByText, screen.getByRole, etc. It mirrors how users see the page.

Example:

render(<Button label="Submit" />)
expect(screen.getByText('Submit')).toBeInTheDocument()

This makes tests easier to understand, and less tightly coupled to component structure.

Create Utility Functions

Repeated actions like rendering components, clicking buttons, or entering input should not be rewritten in every test. Create small helpers to handle them.

For example:

export function renderWithProps(props = {}) {
  return render(<Button label="Default" {...props} />)
}

This saves time and creates consistent testing behavior across multiple test files.

Maintain Clear Naming

Test descriptions should explain what the test is really doing. A vague message like “it works” doesn’t help when it fails. Instead, describe the intent:

it('disables the button when loading', () => {
  render(<Button label="Load" loading />)
  expect(screen.getByRole('button')).toBeDisabled()
})

This clarity becomes more important as test count grows. It also makes it easier to understand which feature failed during CI runs.

Handle Asynchronous Code Carefully

Tests with asynchronous behavior like API calls, delayed renders, or transitions need clear handling. Use async/await and prefer findBy queries which wait automatically.

Example:

await screen.findByText('Loaded')

Instead of manually waiting with setTimeout or confusing retries, let the test runner handle it. Use React Testing Library’s waitFor only when necessary, like waiting for state changes or side effects.

Use Coverage Reports

Coverage helps understand what’s tested and what isn’t. Add this in your vite.config.ts:

coverage: {
  reporter: ['text', 'html'],
  exclude: ['**/setupTests.ts'],
}

Then run:

npx vitest run --coverage

This creates detailed reports and visual feedback to improve test coverage over time.

Example Test Case

Here’s a clean example for a login form component:

import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import LoginForm from './LoginForm'

describe('LoginForm', () => {
  it('shows error on empty submit', async () => {
    render(<LoginForm />)
    fireEvent.click(screen.getByText('Login'))
    expect(await screen.findByText('Email is required')).toBeInTheDocument()
  })
})

This test checks for user interaction, a validation rule, and asynchronous UI updates, all in a clear and meaningful way.

Use CI Integration

Make testing part of your workflow. Tools like GitHub Actions, GitLab CI, or CircleCI can automatically run npm run test on pull requests. This ensures that your codebase stays clean and prevents regressions.

Even a simple workflow like:

- name: Run Vitest
  run: npm run test

can have a huge impact over time.

Conclusion

Combining Vitest and TypeScript opens the door to faster, cleaner, and more reliable frontend testing. But without good habits—clear naming, careful mocking, reusable helpers, and coverage awareness—the setup can become just another checkbox. Thoughtfully written tests add value and reduce bugs. They don’t just catch mistakes—they improve how we design, refactor, and maintain code. When testing becomes part of how a feature is built instead of something added later, it moves from a burden to an essential part of the developer experience.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top