İçeriğe geç
TypeScript

TypeScript Test Stratejileri: Vitest ve Jest

Tarık Tunç
TypeScript Test Stratejileri: Vitest ve Jest

TypeScript Test Stratejileri: Vitest ve Jest

Test yazmak, sürdürülebilir yazılım geliştirmenin temel taşıdır. TypeScript projelerinde tip güvenliği ile test kalitesini birleştirmek, hataları hem derleme hem de runtime aşamasında yakalamanızı sağlar. Bu rehberde Vitest ve Jest kullanarak TypeScript projeleriniz için etkili test stratejileri, konfigürasyon, mock'lama ve en iyi uygulamaları inceleyeceğiz.

Vitest vs Jest: Hangisini Seçmeli?

ÖzellikVitestJest
HızÇok hızlı (Vite tabanlı)Orta
TypeScript desteğiYerleşik (SWC/esbuild)ts-jest veya @swc/jest gerekir
ESM desteğiDoğalKonfigürasyon gerektirir
Watch moduÇok hızlı HMRİyi
Jest uyumluluğuBüyük ölçüde uyumlu-
EkosistemBüyüyenÇok geniş

Vitest Kurulumu

# Kurulum
npm install -D vitest @vitest/coverage-v8 @vitest/ui

# React projeleri için
npm install -D @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.d.ts', 'src/**/*.test.{ts,tsx}'],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
// tests/setup.ts
import '@testing-library/jest-dom/vitest';

// Global mock'lar
vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
  }),
  usePathname: () => '/',
  useSearchParams: () => new URLSearchParams(),
}));

Jest ile TypeScript Kurulumu

# Kurulum
npm install -D jest ts-jest @types/jest
# veya SWC ile (daha hızlı)
npm install -D jest @swc/jest @types/jest
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterSetup: ['./tests/setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transform: {
    '^.+\\.tsx?$': ['ts-jest', {
      tsconfig: 'tsconfig.json',
    }],
  },
  coverageDirectory: 'coverage',
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
  ],
};

export default config;

Unit Test Yazma

// utils/formatters.ts
export function formatCurrency(
  amount: number,
  currency: string = 'TRY',
  locale: string = 'tr-TR'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/ğ/g, 'g')
    .replace(/ü/g, 'u')
    .replace(/ş/g, 's')
    .replace(/ı/g, 'i')
    .replace(/ö/g, 'o')
    .replace(/ç/g, 'c')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

// utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, slugify } from './formatters';

describe('formatCurrency', () => {
  it('TRY para birimini doğru formatlar', () => {
    expect(formatCurrency(1234.56)).toBe('₺1.234,56');
  });

  it('USD para birimini doğru formatlar', () => {
    const result = formatCurrency(1000, 'USD', 'en-US');
    expect(result).toBe('$1,000.00');
  });

  it('negatif değerleri formatlar', () => {
    const result = formatCurrency(-500);
    expect(result).toContain('500');
  });
});

describe('slugify', () => {
  it('Türkçe karakterleri dönüştürür', () => {
    expect(slugify('Türkçe Başlık')).toBe('turkce-baslik');
  });

  it('boşlukları tire ile değiştirir', () => {
    expect(slugify('merhaba dünya')).toBe('merhaba-dunya');
  });

  it('özel karakterleri kaldırır', () => {
    expect(slugify('test!@#$%abc')).toBe('test-abc');
  });

  it('baş ve sondaki tireleri kaldırır', () => {
    expect(slugify('--test--')).toBe('test');
  });
});

Async Fonksiyon Testleri

// services/userService.ts
interface User {
  id: string;
  email: string;
  name: string;
}

interface UserRepository {
  findById(id: string): Promise<User | null>;
  create(data: Omit<User, 'id'>): Promise<User>;
}

export class UserService {
  constructor(private repo: UserRepository) {}

  async getUser(id: string): Promise<User> {
    const user = await this.repo.findById(id);
    if (!user) {
      throw new Error(`User not found: ${id}`);
    }
    return user;
  }

  async createUser(email: string, name: string): Promise<User> {
    if (!email.includes('@')) {
      throw new Error('Geçersiz email');
    }
    return this.repo.create({ email, name });
  }
}

// services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from './userService';

describe('UserService', () => {
  const mockRepo = {
    findById: vi.fn(),
    create: vi.fn(),
  };
  let service: UserService;

  beforeEach(() => {
    vi.clearAllMocks();
    service = new UserService(mockRepo);
  });

  describe('getUser', () => {
    it('kullanıcıyı ID ile döndürür', async () => {
      const mockUser = { id: '1', email: 'a@b.com', name: 'Test' };
      mockRepo.findById.mockResolvedValue(mockUser);

      const user = await service.getUser('1');

      expect(user).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
    });

    it('kullanıcı bulunamazsa hata fırlatır', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getUser('999'))
        .rejects.toThrow('User not found: 999');
    });
  });

  describe('createUser', () => {
    it('geçersiz email ile hata fırlatır', async () => {
      await expect(service.createUser('invalid', 'Test'))
        .rejects.toThrow('Geçersiz email');
    });

    it('geçerli bilgilerle kullanıcı oluşturur', async () => {
      const mockUser = { id: '1', email: 'a@b.com', name: 'Test' };
      mockRepo.create.mockResolvedValue(mockUser);

      const result = await service.createUser('a@b.com', 'Test');

      expect(result).toEqual(mockUser);
      expect(mockRepo.create).toHaveBeenCalledWith({
        email: 'a@b.com',
        name: 'Test',
      });
    });
  });
});

React Component Testleri

// components/SearchInput.tsx
'use client';
import { useState, useCallback } from 'react';

interface SearchInputProps {
  onSearch: (query: string) => void;
  placeholder?: string;
}

export function SearchInput({ onSearch, placeholder = 'Ara...' }: SearchInputProps) {
  const [value, setValue] = useState('');

  const handleSubmit = useCallback(
    (e: React.FormEvent) => {
      e.preventDefault();
      if (value.trim()) onSearch(value.trim());
    },
    [value, onSearch]
  );

  return (
    <form onSubmit={handleSubmit} role="search">
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder={placeholder}
        aria-label="Arama"
      />
      <button type="submit">Ara</button>
    </form>
  );
}

// components/SearchInput.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { SearchInput } from './SearchInput';

describe('SearchInput', () => {
  it('placeholder gösterir', () => {
    render(<SearchInput onSearch={vi.fn()} placeholder="Ürün ara..." />);
    expect(screen.getByPlaceholderText('Ürün ara...')).toBeInTheDocument();
  });

  it('form submit edildiğinde onSearch çağrılır', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup();

    render(<SearchInput onSearch={onSearch} />);

    await user.type(screen.getByRole('textbox'), 'typescript');
    await user.click(screen.getByRole('button', { name: 'Ara' }));

    expect(onSearch).toHaveBeenCalledWith('typescript');
  });

  it('boş değerle arama yapmaz', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup();

    render(<SearchInput onSearch={onSearch} />);

    await user.click(screen.getByRole('button', { name: 'Ara' }));

    expect(onSearch).not.toHaveBeenCalled();
  });
});
İpucu: @testing-library/user-event kütüphanesi, gerçek kullanıcı etkileşimlerini simüle eder ve fireEvent'e göre daha güvenilir testler yazmanızı sağlar.

Tip Testleri

TypeScript tiplerinin doğru çalıştığını test etmek için expect-type kütüphanesini kullanabilirsiniz:

// npm install -D expect-type
import { expectTypeOf, describe, it } from 'vitest';
import { createUser, type User } from './userService';

describe('Tip testleri', () => {
  it('createUser doğru tipi döndürür', () => {
    expectTypeOf(createUser).returns.resolves.toEqualTypeOf<User>();
  });

  it('User tipi doğru alanları içerir', () => {
    expectTypeOf<User>().toHaveProperty('id');
    expectTypeOf<User>().toHaveProperty('email');
  });
});
Uyarı: Tip testleri derleme zamanında çalışır ve runtime'da bir etkisi yoktur. Ancak refactoring sırasında tip uyumluluğunun korunmasını garantiler.

Coverage ve CI Entegrasyonu

// package.json scripts
{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

# GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run test:coverage
      - uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info

Sonuç

TypeScript projelerinde etkili test stratejileri, tip güvenliği ile runtime güvenliğini birleştirir. Vitest modern projeler için hız ve kullanım kolaylığı sunarken, Jest geniş ekosistemi ile güçlü bir alternatiftir. Unit testler ile fonksiyon mantığını, integration testler ile servis etkileşimlerini, component testler ile UI davranışını doğrulayın. Coverage hedeflerinizi belirleyin ama sadece anlamlı testler yazın.

Kaynaklar

İlgili Yazılar