Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 080 / 100

Testing TypeScript with Vitest: best practices explained

Fast tests with native TS support.

Expona Admin8 min read

Over the past decade, we've watched test runners evolve from simple assertion libraries to complex ecosystems that shape how we write and ship code. Testing TypeScript with Vitest: best practices explained dives into the modern testing experience: a tool built from the ground up for speed, native TypeScript support, and developer ergonomics. If you're still wrestling with Jest configuration files or waiting for test suites to complete, Vitest offers a refreshing alternative that deserves your attention.

Why Vitest matters in 2024

Before we explore the specifics, let's establish why testing matters and why the right tool changes everything. In production systems—whether you're handling e-commerce transactions, managing real-time ride-hailing data, or orchestrating complex SaaS workflows—a single untested code path can cascade into customer-facing failures. The testing pyramid guides us: write many unit tests, fewer integration tests, and minimal end-to-end tests. Vitest makes the bottom of that pyramid extraordinarily efficient.

Unlike Jest, which reinvents the test execution model, Vitest leverages Vite's transformation pipeline and ESM-native approach. This isn't just marginally faster—we're talking 5-10x speed improvements in real projects. The team that builds Vitest understands the modern JavaScript ecosystem: unbundled development, ESM modules, and the need for instant feedback during active development.

Fast by default — Vite-powered, ESM-first and watch mode

Speed is the first difference you'll notice. Vitest runs on top of Vite, which means it inherits Vite's lightning-fast module resolution, native ESM support, and minimal transformation overhead. When you enter watch mode, Vitest only reruns tests related to changed files, and it does so in milliseconds rather than seconds.

Here's a typical Vitest configuration that showcases this speed advantage:

typescript
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
    include: ['src/**/*.{test,spec}.{js,ts}'],
    exclude: ['node_modules', 'dist'],
  },
});

In a real e-commerce project, we had a scenario where Jest took 8 seconds to run a suite of 120 tests in watch mode. The same suite with Vitest ran in under 1 second after the initial load. That difference accumulates: over a 30-minute development session, you save 15+ minutes of idle time. More importantly, you stay in flow state instead of context-switching while waiting for feedback.

ESM-first architecture means Vitest doesn't need to transpile CommonJS modules or maintain compatibility layers. Node.js 18+ supports ESM natively, and Vitest leverages this directly. When you import a module in a test, Vitest loads the actual source file without intermediate transformation steps (unless required by your tooling).

Watch mode goes beyond simply rerunning failed tests. Vitest's smart invalidation tracks dependencies: if you change a utility function, only tests that import that utility rerun. This keeps feedback tight and focused.

Jest-compatible API — describe, it, expect and mocks

One of Vitest's smartest decisions was compatibility with Jest's API. If you've written tests before, you already know Vitest. There's no learning curve for the assertion syntax or test structure—describe, it, expect, and mock utilities work identically.

Here's a practical example from a trading system where we needed to test order placement logic:

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PlaceOrderService } from '../services/placeOrderService';
import { OrderRepository } from '../repositories/orderRepository';
import { NotificationService } from '../services/notificationService';

describe('PlaceOrderService', () => {
  let service: PlaceOrderService;
  let mockRepository: any;
  let mockNotification: any;

  beforeEach(() => {
    mockRepository = {
      save: vi.fn().mockResolvedValue({ id: '123', status: 'pending' }),
      findById: vi.fn(),
    };
    mockNotification = {
      sendOrderConfirmation: vi.fn().mockResolvedValue(true),
    };
    service = new PlaceOrderService(mockRepository, mockNotification);
  });

  it('should save order and send notification on success', async () => {
    const order = {
      userId: 'user-1',
      symbol: 'AAPL',
      quantity: 100,
      price: 150.5,
    };

    const result = await service.placeOrder(order);

    expect(mockRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        symbol: 'AAPL',
        quantity: 100,
      })
    );
    expect(mockNotification.sendOrderConfirmation).toHaveBeenCalledWith(
      expect.objectContaining({ userId: 'user-1' })
    );
    expect(result.status).toBe('pending');
  });

  it('should not send notification if order save fails', async () => {
    mockRepository.save.mockRejectedValueOnce(
      new Error('Database unavailable')
    );

    await expect(
      service.placeOrder({ userId: 'user-1', symbol: 'AAPL', quantity: 100 })
    ).rejects.toThrow('Database unavailable');

    expect(mockNotification.sendOrderConfirmation).not.toHaveBeenCalled();
  });
});

Notice how familiar this feels. vi.fn() replaces Jest's jest.fn(), but the pattern is identical. Mock assertions like toHaveBeenCalledWith work the same way. Developers switching from Jest find themselves productive immediately.

Vitest also provides advanced mocking features: spy on existing functions, mock modules globally, and control mock behavior with mockResolvedValue, mockRejectedValue, and mockImplementation. The API surface is comprehensive enough for unit tests, integration tests, and everything in between.

In-source testing — Optional tests next to the code

Vitest introduces an optional pattern borrowed from languages like Rust: embedding tests directly in source files. This is not required, but it's a powerful option for utility functions and small modules.

Consider a complex calculation function in a React Native ride-hailing app that computes surge pricing:

typescript
// surge-pricing.ts
export function calculateSurgeMultiplier(
  activeRequests: number,
  availableDrivers: number,
  timeOfDay: 'peak' | 'off-peak'
): number {
  if (availableDrivers === 0) return 3.0; // Max surge
  const ratio = activeRequests / availableDrivers;
  const baseMultiplier = ratio * (timeOfDay === 'peak' ? 1.5 : 1.0);
  return Math.min(baseMultiplier, 3.0);
}

if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;

  describe('calculateSurgeMultiplier', () => {
    it('returns max surge when no drivers available', () => {
      expect(calculateSurgeMultiplier(10, 0, 'peak')).toBe(3.0);
    });

    it('applies peak-hour multiplier correctly', () => {
      expect(calculateSurgeMultiplier(10, 5, 'peak')).toBe(3.0); // capped
    });

    it('returns lower multiplier during off-peak', () => {
      expect(calculateSurgeMultiplier(5, 10, 'off-peak')).toBe(0.5);
    });
  });
}

The import.meta.vitest check ensures tests only load during test execution, not in production builds. This pattern works best for pure functions or business logic that benefits from being tested alongside its implementation. Developers modifying the function see the tests immediately, reducing the cognitive distance between code and its specification.

For larger projects, we typically use this pattern selectively—perhaps for utility libraries or domain logic—while keeping integration and feature tests in dedicated __tests__ directories.

Coverage — Built-in coverage with v8

Code coverage reveals which parts of your codebase execute during testing. A 100% coverage figure is a vanity metric, but strategic coverage (especially around critical paths in payment processing, authentication, or ride-hailing algorithms) is genuinely valuable.

Vitest ships with built-in v8 coverage, requiring minimal configuration:

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'dist/',
        '**/*.d.ts',
        '**/*.config.ts',
        '**/index.ts',
      ],
      lines: 80,
      functions: 80,
      branches: 75,
      statements: 80,
    },
  },
});

Run coverage with vitest run --coverage. The v8 provider is performant—it adds minimal overhead to test execution. The HTML reporter generates an interactive coverage report showing line-by-line status. The LCOV format integrates with CI/CD pipelines and code review tools.

In a recent SaaS project handling subscription billing, we set branch coverage requirements to 85% for the payment module. This ensured edge cases (failed retries, refund scenarios, currency conversions) received explicit testing rather than being left to chance.

Common mistakes

Mistake 1: Ignoring async/await in tests. Forgetting to await promises or mark tests as async leads to tests that pass falsely. Always ensure your test function properly waits for async operations.

typescript
// Wrong
it('fetches user data', () => {
  const user = fetchUser(1); // Returns a promise
  expect(user.name).toBe('Alice'); // Fails or undefined
});

// Right
it('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

Mistake 2: Over-mocking. Mocking too aggressively can disconnect tests from reality. If you mock a function that's critical to the code path, you're not testing the actual behavior. Use mocks for external services and side effects, but test real logic when possible.

Mistake 3: Assuming watch mode rebuilds everything. Vitest's smart invalidation is efficient, but it relies on module dependency tracking. Editing environment variables or global state may not trigger test reruns. When in doubt, press a in watch mode to rerun all tests.

Mistake 4: Skipping TypeScript in tests. TypeScript in test files catches mock type mismatches and assertion errors early. Don't write tests in plain JavaScript—leverage the type system.

Key takeaways

  • Speed compounds. Vitest's Vite-powered architecture and ESM-first design deliver measurable time savings across development sessions. Watch mode invalidation keeps feedback tight and focused.
  • Compatibility reduces friction. Jest-compatible APIs mean you can adopt Vitest without rewriting test suites or retraining the team. The mental model transfers directly.
  • In-source testing is optional but powerful. Use it strategically for pure functions and business logic where co-location with implementation reduces context-switching.
  • Coverage is a tool, not a goal. Track coverage metrics for critical paths and modules, but don't let coverage percentages dictate test quality. A few well-written tests beat shallow coverage sprawl.
  • Configuration is minimal. Vitest provides sensible defaults. Most projects need only a handful of configuration options, keeping setup simple and cognitive load low.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles