Jest ile JavaScript Unit Test Yazımına Giriş

Prodüksiyona çıkmadan önce bir bug’ı yakalamak, o bug’ı müşteri şikayet ettikten sonra bulmaktan kat kat daha az maliyetlidir. Bu cümleyi her ekip bilir, ama “test yazalım” dediklerinde çoğu zaman iş bir türlü başlamaz. Node.js projelerinde Jest, bu başlangıç engelini en aza indiren araçların başında geliyor. Yapılandırması minimal, sözdizimi sezgisel, geri bildirimleri hızlı. Bu yazıda Jest’i sıfırdan kurup, gerçek bir proje bağlamında nasıl kullanacağınızı aktaracağım.

Neden Jest?

JavaScript ekosisteminde Mocha, Jasmine, Vitest gibi alternatifleri var. Mocha yıllardır kullanılıyor ama ayrıca Chai gibi assertion kütüphaneleri, Sinon gibi mock araçları kurmanız gerekiyor. Jest ise bunların hepsini tek pakette sunuyor: test runner, assertion library, mock sistemi, coverage reporter, hepsi dahil.

Facebook tarafından başlatılan ve şu an Meta bünyesinde geliştirilen Jest, özellikle React projeleriyle birlikte adını duyurdu. Ama sadece frontend için değil, Node.js backend servisleri, CLI araçları, utility kütüphaneleri için de son derece uygun. Ben şu ana kadar onlarca Node.js microservice projesinde Jest kullandım ve neredeyse hiç “keşke başka bir şey kullansaydım” demedim.

Kurulum ve İlk Yapılandırma

Yeni bir Node.js projesi açıyoruz ve Jest’i ekliyoruz:

mkdir jest-ornekleri && cd jest-ornekleri
npm init -y
npm install --save-dev jest

package.json içinde test scriptini ayarlayalım:

# package.json içindeki scripts bölümünü şöyle güncelleyin:
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Jest, varsayılan olarak .test.js veya .spec.js uzantılı dosyaları bulup çalıştırır. Ek yapılandırma yapmadan bu kadar.

TypeScript kullananlar için ek adımlar gerekiyor:

npm install --save-dev @types/jest ts-jest typescript
npx ts-jest config:init

Bu komut jest.config.js dosyasını oluşturuyor. Sıfırdan bir proje başlatıyorsanız TypeScript + Jest kombinasyonunu kurulum aşamasında oturtmanızı öneririm, sonradan eklemek bazen zahmetli olabiliyor.

İlk Testinizi Yazmak

Örnek olarak bir kullanıcı yönetim modülü yazalım. Gerçek bir projede görebileceğiniz türden:

# src/userUtils.js dosyasını oluşturun:

function validateEmail(email) {
  const regex = /^[^s@]+@[^s@]+.[^s@]+$/;
  return regex.test(email);
}

function formatUserName(firstName, lastName) {
  if (!firstName || !lastName) {
    throw new Error('Ad ve soyad zorunludur');
  }
  return `${firstName.trim()} ${lastName.trim()}`;
}

function calculateAge(birthYear) {
  const currentYear = new Date().getFullYear();
  if (birthYear > currentYear) {
    throw new Error('Doğum yılı gelecekte olamaz');
  }
  return currentYear - birthYear;
}

module.exports = { validateEmail, formatUserName, calculateAge };

Şimdi bu modül için test dosyasını oluşturalım:

# src/userUtils.test.js dosyasını oluşturun:

const { validateEmail, formatUserName, calculateAge } = require('./userUtils');

describe('validateEmail', () => {
  test('geçerli email adreslerini doğrulamalı', () => {
    expect(validateEmail('[email protected]')).toBe(true);
    expect(validateEmail('[email protected]')).toBe(true);
  });

  test('geçersiz email adreslerini reddetmeli', () => {
    expect(validateEmail('bu-email-degil')).toBe(false);
    expect(validateEmail('@domain.com')).toBe(false);
    expect(validateEmail('kullanici@')).toBe(false);
    expect(validateEmail('')).toBe(false);
  });
});

describe('formatUserName', () => {
  test('ad ve soyadı birleştirmeli', () => {
    expect(formatUserName('Ali', 'Yılmaz')).toBe('Ali Yılmaz');
  });

  test('baştaki ve sondaki boşlukları temizlemeli', () => {
    expect(formatUserName('  Ayşe  ', '  Kaya  ')).toBe('Ayşe Kaya');
  });

  test('ad veya soyad yoksa hata fırlatmalı', () => {
    expect(() => formatUserName('', 'Yılmaz')).toThrow('Ad ve soyad zorunludur');
    expect(() => formatUserName('Ali', '')).toThrow('Ad ve soyad zorunludur');
    expect(() => formatUserName(null, 'Yılmaz')).toThrow();
  });
});

describe('calculateAge', () => {
  test('doğru yaşı hesaplamalı', () => {
    const currentYear = new Date().getFullYear();
    expect(calculateAge(currentYear - 30)).toBe(30);
  });

  test('gelecekteki doğum yılı için hata fırlatmalı', () => {
    expect(() => calculateAge(2090)).toThrow('Doğum yılı gelecekte olamaz');
  });
});

Testleri çalıştırmak için:

npm test

Terminalde yeşil checkmarklar görüyorsanız her şey yolunda demektir.

describe ve test Bloklarını Anlamak

describe blokları testleri mantıksal gruplar halinde organize etmenizi sağlar. İç içe describe kullanabilirsiniz, bu özellikle büyük sınıfları ya da karmaşık modülleri test ederken çok işe yarıyor.

test ile it aynı şeydir, ikisini de kullanabilirsiniz. Bazı ekipler it('şunu yapmalı') şeklinde yazımı tercih ediyor çünkü İngilizce okunduğunda daha akıcı oluyor: “it should validate email”. Türkçe yazıyorsanız test('email doğrulamalı') daha doğal durur.

beforeEach, afterEach, beforeAll, afterAll hook’ları her test grubunda tekrar eden kurulum ve temizlik işlemleri için kullanılır:

# Veritabanı bağlantısı simüle eden bir örnek:

describe('UserService', () => {
  let db;

  beforeAll(() => {
    // Tüm testler başlamadan bir kez çalışır
    db = createTestDatabase();
  });

  afterAll(() => {
    // Tüm testler bittikten sonra bir kez çalışır
    db.close();
  });

  beforeEach(() => {
    // Her testten önce çalışır
    db.clearAll();
  });

  test('kullanıcı oluşturabilmeli', () => {
    const user = db.createUser({ email: '[email protected]' });
    expect(user.id).toBeDefined();
  });
});

Bu pattern özellikle entegrasyon testlerinde çok sık karşıma çıkıyor. Test izolasyonu için beforeEach içinde veri temizliği yapmak neredeyse standart bir pratik haline geldi.

En Çok Kullanılan Matcher’lar

Jest’in assertion sistemi oldukça zengin. Günlük kullandığım matcher’lar:

  • toBe: Primitive değerleri karşılaştırır (===)
  • toEqual: Obje ve array’leri derin karşılaştırır
  • toBeNull / toBeUndefined / toBeDefined: Null/undefined kontrolleri
  • toBeTruthy / toBeFalsy: Truthy/falsy kontrolleri
  • toContain: Array veya string içinde değer arar
  • toHaveLength: Uzunluk kontrolü
  • toThrow: Hata fırlatma kontrolü
  • toBeGreaterThan / toBeLessThan: Sayısal karşılaştırmalar
  • toMatchObject: Objenin belirli property’leri içerip içermediğini kontrol eder
  • toHaveBeenCalled / toHaveBeenCalledWith: Mock fonksiyon çağrı kontrolleri

toEqual ile toBe arasındaki farkı yeni başlayanlar çoğu zaman karıştırıyor. toBe referans eşitliği kontrol eder, toEqual ise içerik eşitliği:

# Doğru kullanım örnekleri:

test('toBe vs toEqual farkı', () => {
  const obj1 = { isim: 'Ali' };
  const obj2 = { isim: 'Ali' };

  // Bu başarısız olur, farklı referanslar
  // expect(obj1).toBe(obj2);

  // Bu başarılı olur, içerikler aynı
  expect(obj1).toEqual(obj2);

  // Primitive değerlerde toBe kullanın
  expect(2 + 2).toBe(4);
  expect('merhaba').toBe('merhaba');
});

Asenkron Testler

Node.js projelerinde async/await ve Promise’lerle çalışmak kaçınılmaz. Jest bunları doğrudan destekliyor:

# src/apiClient.js - basit bir HTTP istemcisi:

async function fetchUser(userId) {
  if (!userId) {
    throw new Error('userId zorunludur');
  }
  // Gerçek projede burada HTTP isteği olurdu
  const response = await someHttpClient.get(`/users/${userId}`);
  return response.data;
}

module.exports = { fetchUser };
# src/apiClient.test.js:

const { fetchUser } = require('./apiClient');

// Mock'lamadan önce gerçek async testi görelim
describe('async testler', () => {
  test('Promise döndüren fonksiyonu test etmek', async () => {
    // async/await kullanarak
    const sonuc = await somethingAsync();
    expect(sonuc).toBe(beklenen);
  });

  test('hata fırlatan async fonksiyonu test etmek', async () => {
    await expect(fetchUser(null)).rejects.toThrow('userId zorunludur');
  });

  test('resolve olan Promise test etmek', async () => {
    await expect(somePromise()).resolves.toEqual({ status: 'ok' });
  });
});

Eski yöntem olarak done callback’i de var ama async/await varken bunu kullanmak için bir neden göremiyorum. Eğer done callback kullanan eski testler görürseniz bunları modernize etmeyi düşünebilirsiniz.

Mock, Spy ve Stub Kullanımı

Jest’in en güçlü yanlarından biri mock sistemi. Dış bağımlılıkları (veritabanı, HTTP API’ler, dosya sistemi) izole etmeden unit test yazmak hem yavaş hem de güvenilmez olur.

Örnek olarak bir servis katmanı yazalım:

# src/userService.js:

const db = require('./db');
const emailService = require('./emailService');

async function createUser(userData) {
  const existingUser = await db.findByEmail(userData.email);
  if (existingUser) {
    throw new Error('Bu email adresi zaten kayıtlı');
  }

  const user = await db.create(userData);
  await emailService.sendWelcomeEmail(user.email, user.name);
  return user;
}

module.exports = { createUser };
# src/userService.test.js:

const { createUser } = require('./userService');
const db = require('./db');
const emailService = require('./emailService');

// Modülleri tamamen mock'la
jest.mock('./db');
jest.mock('./emailService');

describe('createUser', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('yeni kullanıcı başarıyla oluşturulmalı', async () => {
    // Mock davranışlarını tanımla
    db.findByEmail.mockResolvedValue(null);
    db.create.mockResolvedValue({
      id: 1,
      email: '[email protected]',
      name: 'Ali Yılmaz'
    });
    emailService.sendWelcomeEmail.mockResolvedValue(true);

    const user = await createUser({
      email: '[email protected]',
      name: 'Ali Yılmaz'
    });

    expect(user.id).toBe(1);
    expect(db.create).toHaveBeenCalledWith({
      email: '[email protected]',
      name: 'Ali Yılmaz'
    });
    expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith(
      '[email protected]',
      'Ali Yılmaz'
    );
  });

  test('mevcut email için hata fırlatmalı', async () => {
    db.findByEmail.mockResolvedValue({ id: 99, email: '[email protected]' });

    await expect(
      createUser({ email: '[email protected]', name: 'Ali' })
    ).rejects.toThrow('Bu email adresi zaten kayıtlı');

    expect(db.create).not.toHaveBeenCalled();
    expect(emailService.sendWelcomeEmail).not.toHaveBeenCalled();
  });
});

jest.clearAllMocks() ile jest.resetAllMocks() arasında nüans var. clearAllMocks çağrı geçmişini ve return değerlerini temizler ama mock implementasyonu korur. resetAllMocks implementasyonu da sıfırlar. Çoğu durumda clearAllMocks yeterli, ama bazen resetAllMocks gerekebiliyor, duruma göre karar verin.

Code Coverage Raporu

Coverage olmadan testlerin ne kadar kapsayıcı olduğunu bilemezsiniz. Jest bunu yerleşik olarak destekliyor:

npm run test:coverage

# veya doğrudan:
npx jest --coverage --coverageReporters=text lcov

Çıktıda dört metrik görürsünüz:

  • Statements: Kaç ifade test edildi
  • Branches: if/else gibi dallanmalar
  • Functions: Kaç fonksiyon çağrıldı
  • Lines: Kaç satır kodu test kapsamında

HTML raporu için coverage/lcov-report/index.html dosyasını tarayıcıda açabilirsiniz. Hangi satırların test edilmediğini renk kodlamasıyla gösterir, çok kullanışlı.

Coverage eşiği belirlemek için jest.config.js içine:

# jest.config.js:

module.exports = {
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/index.js'
  ]
};

Bu yapılandırmayla eşiğin altına düşerseniz CI/CD pipeline’ınız başarısız olacak. %100 coverage hedeflemek çoğu zaman anlamsız ve zaman kaybı. Kritik iş mantığı için yüksek coverage, boilerplate kodlar için daha esnek olmak daha pragmatik bir yaklaşım.

Gerçek Dünya Senaryosu: Express Route Testi

Bir Express.js route handler’ını test etmek çok yaygın bir senaryo. supertest paketi burada işe yarıyor:

npm install --save-dev supertest
# src/routes/users.test.js:

const request = require('supertest');
const app = require('../app');
const db = require('../db');

jest.mock('../db');

describe('POST /api/users', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  test('201 ve yeni kullanıcı döndürmeli', async () => {
    db.findByEmail.mockResolvedValue(null);
    db.create.mockResolvedValue({
      id: 1,
      email: '[email protected]',
      name: 'Test Kullanıcı'
    });

    const response = await request(app)
      .post('/api/users')
      .send({ email: '[email protected]', name: 'Test Kullanıcı' })
      .expect('Content-Type', /json/)
      .expect(201);

    expect(response.body.email).toBe('[email protected]');
  });

  test('geçersiz email için 400 döndürmeli', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'gecersiz-email', name: 'Test' })
      .expect(400);

    expect(response.body.error).toBeDefined();
  });
});

Bu yaklaşım tam bir entegrasyon testi değil ama route handler’ınızın beklenen HTTP yanıtlarını ürettiğini doğrulamak için son derece etkili.

CI/CD Entegrasyonu

GitHub Actions ile Jest’i entegre etmek:

# .github/workflows/test.yml:

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Node.js kurulumu
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:coverage
      - name: Coverage raporunu yükle
        uses: codecov/codecov-action@v3

npm ci yerine npm install kullanmayın. ci komutu package-lock.json‘ı birebir kullanır ve daha deterministik bir ortam sağlar. Pipeline’da sürpriz bağımlılık değişikliklerini bu şekilde önlemiş olursunuz.

Sık Yapılan Hatalar

Yeni başlayanların ve bazen deneyimlilerin düştüğü tuzaklar:

  • Async testlerde await unutmak: Test başarılı görünür ama aslında hiçbir şeyi test etmez. Her async test fonksiyonunda async ve await kullandığınızdan emin olun.
  • Mock’ları temizlememek: beforeEach içinde jest.clearAllMocks() çağırmayı unutursanız testler birbirini etkiler ve sıra bağımlı hatalar oluşur.
  • İmplementation detaylarını test etmek: Bir fonksiyonun içinde hangi yardımcı fonksiyonu kaç kez çağırdığını test etmek kırılgan testler üretir. Davranışı test edin, implementasyonu değil.
  • Çok az ya da çok fazla mock: Her şeyi mock’lamak testlerin değerini düşürür. Sadece gerçekten dış bağımlılık olan şeyleri mock’layın.
  • Test isimlerini özensiz yazmak: test('çalışıyor') yerine test('geçersiz email girişinde 400 döndürmeli') yazın. Hata mesajı okunaklı olsun.

Sonuç

Jest, Node.js projelerine minimum friction ile test kültürü kazandırmanın en pratik yollarından biri. Kurulum süresi dakikalar, öğrenme eğrisi diğer test araçlarına kıyasla çok daha düz. Bu yazıda temel kavramları, mock kullanımını ve gerçek proje senaryolarını ele aldık.

Test yazmak başlangıçta yavaşlatan bir şey gibi hissettiriyor ama birkaç ay sonra baktığınızda sizi kaç kez kurtardığını görürsünüz. Özellikle refactoring süreçlerinde iyi bir test paketi olmadan büyük değişiklik yapmak gerçekten cesaret ister. Testler bu cesareti veriyor.

Ekibinizde test kültürü oluşturmaya çalışıyorsanız önce en kritik iş mantığından başlayın, %100 coverage hedefini bir kenara bırakın ve küçük adımlarla ilerleyin. Mükemmeli beklemek çoğu zaman hiç başlamamakla sonuçlanır.

Bir yanıt yazın

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