TypeScript Projelerinde Jest ile Type Safe Test Yazımı

TypeScript ile test yazarken bir noktada fark ediyorsunuz: tip güvenliği olan bir dili test ederken, testlerin kendisi de aynı güvenliği taşımalı. Yoksa runtime’da patlamayan ama mantıksal olarak yanlış olan testler yazıp “yeşil ışık” görüyorsunuz, deployment yapıyorsunuz ve production’da bir şeyler patlıyor. Bu yazıda Jest’i TypeScript projelerinde nasıl doğru konfigüre edeceğinizi, tip güvenli mock’lar nasıl yazacağınızı ve gerçek dünyada karşılaştığım senaryoları aktaracağım.

Neden Type Safe Test?

Klasik JavaScript testlerinde mock objeler yazarken şöyle bir şey yapabiliyordunuz:

const mockUser = {
  id: 1,
  name: "Ahmet"
  // email alanını unuttunuz ama test yine de çalışıyor
};

TypeScript kullanıyorsunuz ama test dosyasında any tip kullanıyorsanız ya da interface’e uymayan mock objeler yazıyorsanız, TypeScript’in size sağladığı güvenlik ağını delip geçiyorsunuz demektir. Gerçekten yaşadığım bir senaryoyu anlatayım: bir projede UserService interface’i değişti, getUser() metodu artık Promise döndürüyor oldu, ama testlerde hep Promise mockluyorduk. Tüm testler yeşildi, CI geçti, production’da null kontrolü yapılmayan yerlerde uygulama çöktü.

Kurulum ve Konfigürasyon

Önce temel kurulumu doğru yapalım. Birçok projede yarım yamalak konfigürasyonlar görüyorum, bu yüzden sıfırdan gidelim.

npm install --save-dev jest @types/jest ts-jest typescript
# ya da
yarn add -D jest @types/jest ts-jest typescript

ts-jest kullanmak yerine Babel ile de gidebilirsiniz ama tip kontrolü istiyorsanız ts-jest şart. Babel sadece TypeScript’i transpile eder, tip kontrolü yapmaz. Testleriniz hatalı tipler içerse bile geçer. Bu da type safe test yazımının amacına aykırı.

jest.config.ts dosyasını şöyle oluşturun:

import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/*.spec.ts', '**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
    '!src/**/*.interface.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

export default config;

Dikkat etmeniz gereken bir nokta: ts-jest‘in kendi tsconfig ayarları var. Eğer projenizde strict: true kullanıyorsanız (kullanmalısınız), testler için ayrı bir tsconfig.test.json oluşturmanızı öneririm:

# tsconfig.test.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false
  },
  "include": ["src/**/*.ts", "src/**/*.test.ts", "src/**/__tests__/**/*.ts"]
}

Testlerde bazı geçici değişkenler tanımlayıp kullanmayabilirsiniz, bu yüzden noUnusedLocals ve noUnusedParameters‘ı kapatıyoruz ama strict moddan çıkmıyoruz.

Interface Tabanlı Mock Yazımı

İşte asıl meseleye geliyoruz. TypeScript’te mock yazmanın en temiz yolu jest.fn() ile interface’i birleştirmek. Şöyle bir servis interface’imiz olsun:

// src/services/user.service.interface.ts
export interface IUserService {
  findById(id: number): Promise<User | null>;
  findAll(): Promise<User[]>;
  create(data: CreateUserDto): Promise<User>;
  update(id: number, data: UpdateUserDto): Promise<User | null>;
  delete(id: number): Promise<boolean>;
}

export interface User {
  id: number;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: Date;
}

export interface CreateUserDto {
  email: string;
  name: string;
  role?: 'admin' | 'user' | 'moderator';
}

export interface UpdateUserDto {
  email?: string;
  name?: string;
  role?: 'admin' | 'user' | 'moderator';
}

Bu interface’i kullanan bir controller’ı test edelim:

// src/controllers/user.controller.test.ts
import { UserController } from '../user.controller';
import { IUserService, User } from '../services/user.service.interface';

// Type-safe mock factory
function createMockUserService(): jest.Mocked<IUserService> {
  return {
    findById: jest.fn(),
    findAll: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn()
  };
}

describe('UserController', () => {
  let controller: UserController;
  let mockUserService: jest.Mocked<IUserService>;

  const mockUser: User = {
    id: 1,
    email: '[email protected]',
    name: 'Ahmet Yılmaz',
    role: 'user',
    createdAt: new Date('2024-01-15')
  };

  beforeEach(() => {
    mockUserService = createMockUserService();
    controller = new UserController(mockUserService);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('mevcut kullanıcıyı döndürmeli', async () => {
      mockUserService.findById.mockResolvedValue(mockUser);

      const result = await controller.getUser(1);

      expect(result).toEqual(mockUser);
      expect(mockUserService.findById).toHaveBeenCalledWith(1);
      expect(mockUserService.findById).toHaveBeenCalledTimes(1);
    });

    it('kullanıcı bulunamazsa null döndürmeli', async () => {
      mockUserService.findById.mockResolvedValue(null);

      const result = await controller.getUser(999);

      expect(result).toBeNull();
    });
  });
});

jest.Mocked kullanımı burada kritik. Bu tip sayesinde mockUserService.findById çağırdığınızda TypeScript, bunun jest.MockedFunction tipinde olduğunu biliyor ve .mockResolvedValue(), .mockReturnValue() gibi metodları otomatik tamamlıyor. Eğer mockResolvedValue içine yanlış tip verirseniz, TypeScript derleme hatası veriyor.

Partial Mock ile Gerçekçi Objeler

Bazen tam mock yerine gerçek implementasyonun bir kısmını kullanmak istiyorsunuz. Özellikle utility sınıfları için bu çok işe yarıyor:

// src/utils/date.util.test.ts
import { NotificationService } from '../notification.service';
import { EmailService } from '../email.service';
import { DateUtil } from '../utils/date.util';

describe('NotificationService', () => {
  let notificationService: NotificationService;
  let mockEmailService: jest.Mocked<EmailService>;

  beforeEach(() => {
    // EmailService'in sadece belirli metodlarını mock'la
    mockEmailService = {
      ...new EmailService(), // gerçek implementasyonu al
      sendEmail: jest.fn().mockResolvedValue({ messageId: 'mock-id-123' }),
      sendBulkEmail: jest.fn().mockResolvedValue([])
    } as jest.Mocked<EmailService>;

    notificationService = new NotificationService(
      mockEmailService,
      new DateUtil() // gerçek DateUtil kullan
    );
  });

  it('doğum günü bildirimi gönderilmeli', async () => {
    const users = [
      { id: 1, email: '[email protected]', name: 'Ali', birthDate: new Date() }
    ];

    await notificationService.sendBirthdayNotifications(users);

    expect(mockEmailService.sendEmail).toHaveBeenCalledWith({
      to: '[email protected]',
      subject: expect.stringContaining('Doğum Günün Kutlu Olsun'),
      body: expect.any(String)
    });
  });
});

Custom Matchers ile Tip Güvenli Assertions

Jest’in built-in matcher’ları çoğu zaman yeterli ama domain-specific assertion’lar için custom matcher yazmak hem kodu okunabilir kılıyor hem de hata mesajlarını anlamlılaştırıyor:

// src/test-utils/custom-matchers.ts
import { User } from '../services/user.service.interface';

declare global {
  namespace jest {
    interface Matchers<R> {
      toBeValidUser(): R;
      toHaveRole(role: User['role']): R;
    }
  }
}

expect.extend({
  toBeValidUser(received: unknown) {
    const isValid =
      typeof received === 'object' &&
      received !== null &&
      typeof (received as User).id === 'number' &&
      typeof (received as User).email === 'string' &&
      (received as User).email.includes('@') &&
      typeof (received as User).name === 'string' &&
      ['admin', 'user', 'moderator'].includes((received as User).role);

    return {
      pass: isValid,
      message: () =>
        isValid
          ? `Beklenen obje geçerli bir User olmamalıydı`
          : `Beklenen obje geçerli bir User olmalıydı. Alınan: ${JSON.stringify(received, null, 2)}`
    };
  },

  toHaveRole(received: User, expectedRole: User['role']) {
    const pass = received.role === expectedRole;
    return {
      pass,
      message: () =>
        pass
          ? `Kullanıcının rolü '${expectedRole}' olmamalıydı`
          : `Kullanıcının rolünün '${expectedRole}' olmasını bekliyordu ama '${received.role}' bulundu`
    };
  }
});

// Test dosyasında kullanımı:
// import '../test-utils/custom-matchers';
// expect(response.user).toBeValidUser();
// expect(adminUser).toHaveRole('admin');

Bu yaklaşımın bir başka faydası: User['role'] kullandığımız için, eğer interface’deki rol tipleri değişirse bu matcher da derleme hatası verecek.

Async/Await ve Error Handling Testleri

Production kodunda en sık hata gördüğüm alan: async hata durumlarının eksik test edilmesi. Şöyle bir pattern kullanıyorum:

// src/services/payment.service.test.ts
import { PaymentService } from '../payment.service';
import { IPaymentGateway, PaymentResult } from '../gateways/payment.gateway.interface';

describe('PaymentService', () => {
  let paymentService: PaymentService;
  let mockGateway: jest.Mocked<IPaymentGateway>;

  beforeEach(() => {
    mockGateway = {
      charge: jest.fn(),
      refund: jest.fn(),
      getTransaction: jest.fn()
    };
    paymentService = new PaymentService(mockGateway);
  });

  describe('processPayment', () => {
    it('başarılı ödeme işlemini doğru handle etmeli', async () => {
      const mockResult: PaymentResult = {
        transactionId: 'txn_abc123',
        status: 'success',
        amount: 150.00,
        currency: 'TRY',
        processedAt: new Date()
      };

      mockGateway.charge.mockResolvedValue(mockResult);

      const result = await paymentService.processPayment({
        userId: 1,
        amount: 150.00,
        currency: 'TRY'
      });

      expect(result.transactionId).toBe('txn_abc123');
      expect(result.status).toBe('success');
    });

    it('gateway hatası durumunda PaymentException fırlatmalı', async () => {
      mockGateway.charge.mockRejectedValue(
        new Error('INSUFFICIENT_FUNDS')
      );

      await expect(
        paymentService.processPayment({
          userId: 1,
          amount: 9999.00,
          currency: 'TRY'
        })
      ).rejects.toThrow('Yetersiz bakiye');
    });

    it('network timeout durumunda retry mekanizması çalışmalı', async () => {
      const timeoutError = new Error('NETWORK_TIMEOUT');
      const successResult: PaymentResult = {
        transactionId: 'txn_retry_success',
        status: 'success',
        amount: 50.00,
        currency: 'TRY',
        processedAt: new Date()
      };

      // İlk iki denemede hata, üçüncüde başarı
      mockGateway.charge
        .mockRejectedValueOnce(timeoutError)
        .mockRejectedValueOnce(timeoutError)
        .mockResolvedValueOnce(successResult);

      const result = await paymentService.processPayment({
        userId: 1,
        amount: 50.00,
        currency: 'TRY'
      });

      expect(result.transactionId).toBe('txn_retry_success');
      expect(mockGateway.charge).toHaveBeenCalledTimes(3);
    });
  });
});

mockRejectedValueOnce ve mockResolvedValueOnce zincirlemesi, retry senaryolarını test etmek için harika. Bu tür testleri görmezden gelen projelerde retry mantığının hiç çalışmadığını production’da fark ettik, hatırası taze.

Module Mocking ve Dependency Injection

Bazen dışarıdan enjekte edemediğiniz modülleri mock’lamak zorunda kalıyorsunuz. jest.mock() kullanırken tip güvenliğini korumak için şu pattern işe yarıyor:

// src/services/cache.service.test.ts
import { CacheService } from '../cache.service';

// Redis modülünü mock'la
jest.mock('ioredis', () => {
  return jest.fn().mockImplementation(() => ({
    get: jest.fn(),
    set: jest.fn(),
    del: jest.fn(),
    expire: jest.fn(),
    quit: jest.fn()
  }));
});

import Redis from 'ioredis';

describe('CacheService', () => {
  let cacheService: CacheService;
  let mockRedis: jest.Mocked<Redis>;

  beforeEach(() => {
    // jest.Mocked kullanarak tip güvenli erişim
    mockRedis = new Redis() as jest.Mocked<Redis>;
    cacheService = new CacheService(mockRedis);
  });

  it('cache miss durumunda null döndürmeli', async () => {
    (mockRedis.get as jest.Mock).mockResolvedValue(null);

    const result = await cacheService.get<string>('nonexistent-key');

    expect(result).toBeNull();
    expect(mockRedis.get).toHaveBeenCalledWith('nonexistent-key');
  });

  it('cache hit durumunda deserialize edilmiş değeri döndürmeli', async () => {
    const cachedData = { userId: 42, sessionToken: 'abc' };
    (mockRedis.get as jest.Mock).mockResolvedValue(JSON.stringify(cachedData));

    const result = await cacheService.get<typeof cachedData>('session:42');

    expect(result).toEqual(cachedData);
  });

  it('set işlemi TTL ile birlikte çağrılmalı', async () => {
    (mockRedis.set as jest.Mock).mockResolvedValue('OK');
    (mockRedis.expire as jest.Mock).mockResolvedValue(1);

    await cacheService.set('key', { data: 'value' }, 3600);

    expect(mockRedis.set).toHaveBeenCalledWith(
      'key',
      expect.any(String)
    );
    expect(mockRedis.expire).toHaveBeenCalledWith('key', 3600);
  });
});

Test Utility’leri ve Factory Pattern

Büyük projelerde aynı mock datayı sürekli tekrar yazmak hem zaman kaybı hem de bakım sorunu. Builder/Factory pattern ile test verilerini merkezi yönetin:

// src/test-utils/factories/user.factory.ts
import { User, CreateUserDto } from '../../services/user.service.interface';

let idCounter = 1;

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    id: idCounter++,
    email: `test-user-${idCounter}@example.com`,
    name: `Test Kullanıcı ${idCounter}`,
    role: 'user',
    createdAt: new Date('2024-01-01'),
    ...overrides
  };
}

export function buildAdminUser(overrides: Partial<User> = {}): User {
  return buildUser({
    role: 'admin',
    email: `admin-${idCounter}@example.com`,
    ...overrides
  });
}

export function buildCreateUserDto(overrides: Partial<CreateUserDto> = {}): CreateUserDto {
  return {
    email: `new-user-${Date.now()}@example.com`,
    name: 'Yeni Kullanıcı',
    role: 'user',
    ...overrides
  };
}

// Kullanımı:
// const user = buildUser({ name: 'Mehmet', role: 'moderator' });
// const admin = buildAdminUser({ email: '[email protected]' });

Bu factory fonksiyonları Partial alıyor, yani sadece değiştirmek istediğiniz alanları geçiyorsunuz. TypeScript, geçerli olmayan bir alan geçerseniz hemen hata veriyor. idCounter ile her test için benzersiz ID’ler üretiyoruz, paralel testlerde çakışma olmuyor.

Coverage Raporları ve Anlamlı Metrikler

Coverage’ı salt yüzde hedefi olarak görmeyin. Şunu sık görüyorum: %95 coverage var ama kritik bir branch test edilmemiş. Jest’in --coverage flagiyle birlikte lcov formatında rapor alın ve CI pipeline’a entegre edin:

# package.json scripts
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage --coverageReporters=lcov --coverageReporters=text-summary",
    "test:ci": "jest --ci --coverage --forceExit --detectOpenHandles"
  }
}

--detectOpenHandles bayrağını mutlaka kullanın. Testler bittikten sonra Jest’in neden kapanmadığını araştırmakla vakit harcamak istemezsiniz. Açık kalan database bağlantıları, timer’lar, socket’lar bunun en yaygın sebepleri.

jest.config.ts dosyasında belirlediğimiz coverageThreshold sayesinde coverage düşerse CI otomatik olarak başarısız olur. Ama bunu takım içinde konuşarak belirleyin, yoksa “testleri geçirmek için coverage threshold’u düşürdük” PR’larını görmeye başlarsınız ki bu bizdeki bir proje de gerçekleşti.

Sonuç

TypeScript projelerinde tip güvenli test yazımı başlangıçta biraz ekstra efor gerektiriyor ama bu yatırım kısa sürede geri dönüyor. jest.Mocked kullanımı, interface tabanlı mock factory’ler ve custom matcher’lar sayesinde hem testleriniz değişime dayanıklı oluyor hem de refactoring yaptığınızda TypeScript sizi yönlendiriyor.

Özellikle vurgulamak istediğim noktalar:

  • ts-jest kullanarak gerçek tip kontrolü yapın, Babel ile yetinmeyin
  • jest.Mocked ile mock objelerinizi tip güvenli hale getirin
  • Factory fonksiyonları ile test verilerini merkezi yönetin
  • coverageThreshold belirleyin ama sadece rakama değil, kritik path’lerin test edildiğine bakın
  • –detectOpenHandles ile test sonrası kaynak sızıntılarını yakalayin
  • Custom matcher‘lar ile domain-specific assertion’lar yazın

Production’da yaşanan bir hatayı test ortamında yakalamak, hem teknik hem de insani açıdan çok daha az maliyetli. TypeScript’in tip sistemi bu konuda güçlü bir müttefikiniz ama onu testlerde de aktif kullanmanız gerekiyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir