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:
File | Description |
---|---|
src/components/Button.tsx | The actual button component |
src/components/Button.test.tsx | Tests 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.