Skip to content
Home
TypeScript Testing: Vitest, Playwright, and Type-Aware Testing Guide

TypeScript Testing: Vitest, Playwright, and Type-Aware Testing Guide

TypeScript TypeScript 8 min read 1514 words Beginner ExcellentWiki Editorial Team

Testing TypeScript code combines traditional unit/integration testing with type-level testing — asserting that your types resolve correctly without runtime execution. TypeScript’s type system itself is a testing tool: well-typed code eliminates entire categories of runtime tests. However, runtime testing remains essential for business logic, integration points, and browser behavior. This guide covers testing TypeScript with Vitest (recommended), Jest, and Playwright, plus type-level testing with expect-type and tsd.

Why TypeScript Changes Testing

Traditional JavaScript testing relies heavily on runtime assertions to catch type mismatches — checking that typeof result === 'string' or that result !== undefined. TypeScript eliminates these checks because the compiler guarantees them. This shifts testing focus from defensive assertions to behavioral verification:

  • Before TypeScript: Test that function returns a string, test that property exists, test that null is handled.
  • After TypeScript: Test that business logic produces correct outputs, test edge cases the type system cannot express (empty arrays, boundary values, network failures).

The result is fewer, more focused tests that cover scenarios the type system cannot reach.

Vitest: The Recommended Test Runner

Vitest is a Vite-native test runner compatible with Jest’s API but significantly faster and TypeScript-native. It handles TypeScript files without additional transpilation configuration.

Setup

npm install -D vitest

In vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node', // or 'jsdom' for browser-like environment
  },
---);

For browser projects, install @vitest/ui and set environment: 'jsdom'.

Writing Tests

Vitest’s API mirrors Jest — describe, it, expect — with TypeScript types built in:

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

function greet(name: string): string {
  return `Hello, ${name}!`;
---

describe('greet', () => {
  it('returns a greeting with the provided name', () => {
    expect(greet('World')).toBe('Hello, World!');
  });

  it('works with empty strings', () => {
    expect(greet('')).toBe('Hello, !');
  });
---);

Vitest Configuration for TypeScript Projects

Vitest reads tsconfig.json automatically. For monorepos or complex setups, customize vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
    },
  },
---);

Run tests with npx vitest (watch mode) or npx vitest run (CI mode). Vitest reuses Vite’s module graph for near-instant startup — the first test run takes < 200ms for most projects.

Matchers for TypeScript

Vitest’s expect includes TypeScript-aware matchers via vitest-dom:

import { expect } from 'vitest';

// Type-safe assertions
expect('hello').toBeTypeOf('string');
expect([1, 2, 3]).toHaveLength(3);
expect(() => JSON.parse('invalid')).toThrow();

Type-Level Testing with expect-type

Type-level testing verifies that TypeScript types resolve correctly without running code. This catches type refactoring regressions — if you change a generic signature, type tests break immediately.

Install expect-type:

npm install -D expect-type
import { expectTypeOf } from 'expect-type';

type User = { name: string; age: number };
type PartialUser = Partial<User>;

// Type-level assertions
expectTypeOf<PartialUser>().toMatchTypeOf<{ name?: string; age?: number }>();
expectTypeOf<PartialUser>().not.toBeNever();
expectTypeOf<() => string>().returns.toBeString();

tsd (TypeScript Definition tester) is an alternative by the TypeScript team that works with assertion comments:

import { expectType } from 'tsd';

const result: string = greet('World');
expectType<string>(result);     // passes
expectType<number>(result);     // fails at compile time

Jest with TypeScript

If your project uses Jest, configure it with ts-jest:

npm install -D jest ts-jest @types/jest

jest.config.js:

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: { '^.+\\.tsx?$': 'ts-jest' },
---;

For faster execution, use @swc/jest instead of ts-jest:

npm install -D @swc/jest
module.exports = {
  transform: { '^.+\\.tsx?$': '@swc/jest' },
---;

Playwright for End-to-End Testing

Playwright is the leading browser automation framework with TypeScript-native support. It catches selector errors, assertion mismatches, and navigation bugs at compile time.

Setup

npm init playwright@latest

Playwright generates a playwright.config.ts with TypeScript types:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  use: { baseURL: 'http://localhost:3000' },
---);

Writing E2E Tests

import { test, expect } from '@playwright/test';

test('user can log in', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@example.com');
  await page.fill('[data-testid="password"]', 'password123');
  await page.click('[data-testid="submit"]');
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('h1')).toContainText('Welcome');
---);

The Locator API is fully typed — Playwright’s TypeScript definitions prevent misspelled selectors and provide autocompletion for all built-in locator strategies.

Mocking Strategies

Mocking in TypeScript benefits from type safety — your mocks must conform to the same interfaces as the real implementations. TypeScript catches mock drift when production interfaces change but mocks are not updated.

Vitest Mocking

import { vi } from 'vitest';

const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

// In test
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: 'ok' })));

For module mocking:

vi.mock('./database', () => ({
  query: vi.fn().mockResolvedValue([{ id: 1 }]),
---));

Typing Mocks

Provide type annotations for mock implementations to ensure test setup matches production contracts:

import type { Database } from './database';

const mockDb: Database = {
  query: vi.fn<Database['query']>(),
  connect: vi.fn(),
  close: vi.fn(),
---;

The vi.fn<Database['query']>() syntax ensures the mock has the correct parameter and return types. If the production Database.query signature changes, the mock declaration produces a type error — preventing test-production drift.

Testing Discriminated Unions

Discriminated unions require testing each variant independently. The exhaustive switch pattern provides a compile-time guarantee that adding a new variant requires updating both the handler and its tests:

type Result<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function handleResult<T>(result: Result<T>): string {
  switch (result.status) {
    case 'success': return `Data: ${JSON.stringify(result.data)}`;
    case 'error': return `Error: ${result.message}`;
    case 'loading': return 'Loading...';
  }
---

describe('handleResult', () => {
  it('handles success', () => {
    expect(handleResult({ status: 'success', data: 'hello' })).toBe('Data: "hello"');
  });
  it('handles error', () => {
    expect(handleResult({ status: 'error', message: 'fail' })).toBe('Error: fail');
  });
  it('handles loading', () => {
    expect(handleResult({ status: 'loading' })).toBe('Loading...');
  });
---);

CI Integration

Run type-checking and tests in separate CI steps:

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx tsc --noEmit          # type-check
      - run: npx vitest run             # unit tests
      - run: npx playwright test        # e2e tests

For faster CI, use --noEmit to skip emitting output during type-checking.

CI Integration

Run type-checking and tests in separate CI steps:

# .github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx tsc --noEmit          # type-check
      - run: npx vitest run             # unit tests
      - run: npx playwright test        # e2e tests

For faster CI, use --noEmit to skip emitting output during type-checking, and consider running type-checking and unit tests in parallel:

jobs:
  type-check:
    runs-on: ubuntu-latest
    steps:
      - run: npx tsc --noEmit
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npx vitest run
  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - run: npx playwright test

Parallel execution reduces total CI time. Type-checking typically completes in 5-30 seconds for most projects, while unit tests may take 1-5 minutes. Running them concurrently keeps the feedback loop tight.

FAQ

Do I need both unit tests and type tests?
Yes. Unit tests verify runtime behavior; type tests verify type correctness. A change that breaks your generic utility type will not be caught by unit tests alone.

Should I test implementation details or behavior?
Test behavior (public API contracts), not implementation details. This applies doubly in TypeScript — well-typed public APIs reduce the need for internal testing.

How do I test async code with Vitest?
Use async functions: it('fetches data', async () => { const data = await fetchData(); expect(data).toBeDefined(); }). Vitest handles promises natively and times out async tests after 5 seconds by default (configurable via test.timeout).

What is the best way to test React components with TypeScript?
Use @testing-library/react with Vitest. Component tests verify rendering and interaction while the type system ensures props are provided correctly. Example:

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

it('submits form data', async () => {
  const onSubmit = vi.fn();
  render(<Form onSubmit={onSubmit} />);
  await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
  await userEvent.click(screen.getByText('Submit'));
  expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' });
---);

How do I test a function that uses environment variables?
Mock process.env before imports using vi.stubEnv('MY_VAR', 'value') (Vitest 1.x+) or vi.stubGlobal:

beforeEach(() => {
  vi.stubEnv('API_URL', 'https://test.api.com');
---);

afterEach(() => {
  vi.unstubAllEnvs();
---);

How do I write snapshot tests with TypeScript?
Vitest supports snapshot testing natively:

it('matches snapshot', () => {
  const result = generateHtml({ title: 'Test' });
  expect(result).toMatchSnapshot();
---);

Snapshots are stored as .snap files alongside your tests. Run npx vitest --update to regenerate them when output changes intentionally.

End-to-End Testing with Playwright and TypeScript

Playwright’s integration with TypeScript goes beyond basic typing. The test runner understands locator chains, assertion types, and page object patterns:

import { test, expect, type Page } from '@playwright/test';

class LoginPage {
  constructor(private page: Page) {}

  async navigate() { await this.page.goto('/login'); }
  async fillEmail(email: string) { await this.page.fill('[data-testid="email"]', email); }
  async submit() { await this.page.click('[data-testid="submit"]'); }
  async expectSuccess() { await expect(this.page).toHaveURL('/dashboard'); }
---

test('successful login via page object', async ({ page }) => {
  const login = new LoginPage(page);
  await login.navigate();
  await login.fillEmail('user@example.com');
  await login.submit();
  await login.expectSuccess();
---);

Page object methods return Promise<void>, so forgetting await produces a TypeScript error. This catches one of the most common Playwright mistakes at compile time rather than through a confusing timeout failure.

Internal Links

References

Section: TypeScript 1514 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top