← Курс/Тестирование TypeScript кода#193 из 257+25 XP

Тестирование TypeScript кода

Настройка Vitest с TypeScript

Vitest — современный тест-раннер с нативной поддержкой TypeScript:

npm install -D vitest @vitest/ui
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.{test,spec}.ts'],
  },
})

Базовые тесты с TypeScript

// utils/math.ts
export function add(a: number, b: number): number {
  return a + b
}

// utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { add } from './math'

describe('add', () => {
  it('складывает два числа', () => {
    expect(add(2, 3)).toBe(5)
    expect(add(-1, 1)).toBe(0)
  })

  it('принимает только числа (TypeScript защищает)', () => {
    // @ts-expect-error — намеренно неправильный тип
    expect(() => add('2', 3)).not.toThrow()
  })
})

Тесты дженерик-функций

function first<T>(arr: T[]): T | undefined {
  return arr[0]
}

it('возвращает первый элемент', () => {
  expect(first([1, 2, 3])).toBe(1)
  expect(first(['a', 'b'])).toBe('a')
  expect(first([])).toBeUndefined()
})

expectTypeOf: тесты на уровне типов

Vitest предоставляет expectTypeOf для проверки типов во время тестов:

import { expectTypeOf } from 'vitest'

it('проверяет типы', () => {
  expectTypeOf(add).toBeFunction()
  expectTypeOf(add).parameters.toEqualTypeOf<[number, number]>()
  expectTypeOf(add).returns.toBeNumber()

  expectTypeOf(first<string>).returns.toEqualTypeOf<string | undefined>()
})

Мокирование с типами

import { vi, Mock } from 'vitest'

interface UserService {
  getUser(id: number): Promise<User>
  createUser(data: CreateUserData): Promise<User>
}

// vi.fn() создаёт мок-функцию с правильными типами:
const mockGetUser = vi.fn<[number], Promise<User>>()
mockGetUser.mockResolvedValue({ id: 1, name: 'Алексей' })

// Или через vi.mocked():
const mockService = {
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Алексей' }),
  createUser: vi.fn().mockResolvedValue({ id: 2, name: 'Мария' }),
} satisfies Record<keyof UserService, Mock>

Тестирование async кода

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('User not found')
  return response.json()
}

it('получает пользователя', async () => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ id: 1, name: 'Алексей' }),
  })

  const user = await fetchUser(1)
  expect(user.name).toBe('Алексей')
  expect(fetch).toHaveBeenCalledWith('/api/users/1')
})

it('выбрасывает ошибку при 404', async () => {
  global.fetch = vi.fn().mockResolvedValue({ ok: false })
  await expect(fetchUser(99)).rejects.toThrow('User not found')
})

Тестирование классов с дженериками

class Stack<T> {
  private items: T[] = []
  push(item: T): void { this.items.push(item) }
  pop(): T | undefined { return this.items.pop() }
  peek(): T | undefined { return this.items[this.items.length - 1] }
  get size(): number { return this.items.length }
}

describe('Stack<T>', () => {
  it('работает с числами', () => {
    const stack = new Stack<number>()
    stack.push(1); stack.push(2)
    expect(stack.pop()).toBe(2)
    expect(stack.size).toBe(1)
  })

  it('работает со строками', () => {
    const stack = new Stack<string>()
    stack.push('hello')
    expect(stack.peek()).toBe('hello')
    expect(stack.size).toBe(1)
  })
})

jest.config.ts для Jest

export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: {
    '^.+\.tsx?$': ['ts-jest', { useESM: true }],
  },
}

Примеры

Мини тест-раннер с describe/it/expect/mock — реализация с нуля, демонстрирует как работает Vitest/Jest внутри

// Реализуем минимальный тест-раннер — понимаем как устроены Vitest/Jest.
// В TypeScript тесты имеют строгую типизацию через expectTypeOf.

// --- Мини тест-раннер ---
let passed = 0
let failed = 0
let currentSuite = 'root'

function describe(name, fn) {
  const prev = currentSuite
  currentSuite = name
  console.log(`\n  ${name}`)
  fn()
  currentSuite = prev
}

function it(name, fn) {
  try {
    const result = fn()
    if (result instanceof Promise) {
      return result.then(() => {
        passed++
        console.log(`    ✓ ${name}`)
      }).catch(err => {
        failed++
        console.log(`    ✗ ${name}: ${err.message}`)
      })
    }
    passed++
    console.log(`    ✓ ${name}`)
  } catch (err) {
    failed++
    console.log(`    ✗ ${name}: ${err.message}`)
  }
}

const test = it

function expect(actual) {
  return {
    toBe(expected) {
      if (actual !== expected) {
        throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
      }
    },
    toEqual(expected) {
      if (JSON.stringify(actual) !== JSON.stringify(expected)) {
        throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
      }
    },
    toBeUndefined() {
      if (actual !== undefined) throw new Error(`Expected undefined, got ${actual}`)
    },
    toBeNull() {
      if (actual !== null) throw new Error(`Expected null, got ${actual}`)
    },
    toBeTruthy() {
      if (!actual) throw new Error(`Expected truthy, got ${actual}`)
    },
    toBeFalsy() {
      if (actual) throw new Error(`Expected falsy, got ${actual}`)
    },
    toContain(item) {
      if (Array.isArray(actual)) {
        if (!actual.includes(item)) throw new Error(`Expected array to contain ${item}`)
      } else if (typeof actual === 'string') {
        if (!actual.includes(item)) throw new Error(`Expected string to contain ${item}`)
      }
    },
    toHaveLength(len) {
      if (actual.length !== len) throw new Error(`Expected length ${len}, got ${actual.length}`)
    },
    toThrow(message) {
      if (typeof actual !== 'function') throw new Error('toThrow requires a function')
      try { actual(); throw new Error('Expected to throw') }
      catch (e) {
        if (e.message === 'Expected to throw') throw e
        if (message && !e.message.includes(message)) {
          throw new Error(`Expected error "${message}", got "${e.message}"`)
        }
      }
    },
    resolves: {
      toBe: (expected) => actual.then(v => expect(v).toBe(expected)),
    },
    rejects: {
      toThrow: (msg) => actual.catch(e => {
        if (msg && !e.message.includes(msg)) throw new Error(`Wrong error: ${e.message}`)
      }).then(() => {}, () => { throw new Error('Expected rejection') })
    },
    not: {
      toBe(expected) {
        if (actual === expected) throw new Error(`Expected NOT ${JSON.stringify(expected)}`)
      },
      toBeUndefined() {
        if (actual === undefined) throw new Error('Expected to not be undefined')
      },
    }
  }
}

// --- vi.fn() мок ---
function vi_fn(implementation = () => undefined) {
  const calls = []
  const mockImpl = { current: implementation }

  function mockFn(...args) {
    calls.push(args)
    return mockImpl.current(...args)
  }

  mockFn.mock = { calls }
  mockFn.mockReturnValue = (val) => { mockImpl.current = () => val; return mockFn }
  mockFn.mockResolvedValue = (val) => { mockImpl.current = () => Promise.resolve(val); return mockFn }
  mockFn.mockRejectedValue = (err) => { mockImpl.current = () => Promise.reject(err); return mockFn }
  mockFn.mockImplementation = (fn) => { mockImpl.current = fn; return mockFn }

  return mockFn
}

// --- Тестируемый код (как TypeScript) ---

// TS: function add(a: number, b: number): number
function add(a, b) { return a + b }

// TS: function first<T>(arr: T[]): T | undefined
function first(arr) { return arr[0] }

// TS: class Stack<T>
class Stack {
  constructor() { this.items = [] }
  push(item) { this.items.push(item) }
  pop() { return this.items.pop() }
  peek() { return this.items[this.items.length - 1] }
  get size() { return this.items.length }
}

// TS: async function fetchUser(id: number): Promise<User>
async function fetchUser(id, fetchFn) {
  const response = await fetchFn(`/api/users/${id}`)
  if (!response.ok) throw new Error('User not found')
  return response.json()
}

// --- ТЕСТЫ ---
console.log('Running tests...')

describe('add()', () => {
  it('складывает два числа', () => {
    expect(add(2, 3)).toBe(5)
    expect(add(-1, 1)).toBe(0)
    expect(add(0, 0)).toBe(0)
  })

  it('работает с отрицательными числами', () => {
    expect(add(-5, -3)).toBe(-8)
  })
})

describe('first<T>()', () => {
  it('возвращает первый элемент', () => {
    expect(first([1, 2, 3])).toBe(1)
    expect(first(['a', 'b'])).toBe('a')
  })

  it('возвращает undefined для пустого массива', () => {
    expect(first([])).toBeUndefined()
  })
})

describe('Stack<T>', () => {
  it('push и pop работают корректно', () => {
    const stack = new Stack()
    stack.push(1); stack.push(2); stack.push(3)
    expect(stack.size).toBe(3)
    expect(stack.pop()).toBe(3)
    expect(stack.size).toBe(2)
  })

  it('peek не удаляет элемент', () => {
    const stack = new Stack()
    stack.push('hello')
    expect(stack.peek()).toBe('hello')
    expect(stack.size).toBe(1)
  })

  it('pop из пустого стека возвращает undefined', () => {
    const stack = new Stack()
    expect(stack.pop()).toBeUndefined()
  })
})

describe('fetchUser() с моками', () => {
  it('возвращает пользователя', async () => {
    const mockFetch = vi_fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: 1, name: 'Алексей' }),
    })

    const user = await fetchUser(1, mockFetch)
    expect(user.name).toBe('Алексей')
    expect(mockFetch.mock.calls[0][0]).toBe('/api/users/1')
  })

  it('выбрасывает ошибку при 404', async () => {
    const mockFetch = vi_fn().mockResolvedValue({ ok: false })
    try {
      await fetchUser(99, mockFetch)
      throw new Error('Должно было выбросить ошибку')
    } catch (e) {
      expect(e.message).toBe('User not found')
      passed++
      console.log('    ✓ выбрасывает ошибку при 404')
    }
  })
})

// Итоги (асинхронные тесты завершатся после)
setTimeout(() => {
  console.log(`\n  Results: ${passed} passed, ${failed} failed`)
}, 100)