İçeriğe geç
·19 dk okuma

Vitest Test Rehberi: Sıfırdan Production Güvenine

Unit testler, integration testler, Testing Library ile bileşen testleri, mock stratejileri, coverage yapılandırması ve gerçekten daha iyi yazılım üreten test felsefesi.

Paylaş:X / TwitterLinkedIn

Çoğu takım testleri insanların spor yapması gibi yazar: yapmaları gerektiğini bilirler, yapmadıklarında suçlu hissederler ve nihayet yaptıklarında ilk gün çok zorlayıp cuma günü bırakırlar. Kod tabanında her CSS sınıfı değiştiğinde kırılan birkaç sığ snapshot test, kimsenin güvenmediği birkaç integration test ve README'deki yalan söyleyen bir coverage rozeti kalır.

Ben her iki tarafta da bulundum. Sıfır testle projeler gönderdim ve her deploy'da ter döktüm. %100 coverage peşinde koşan takımlarda da bulundum ve feature yazmaktan çok test bakımıyla uğraştık. İkisi de işe yaramıyor. İşe yarayan şey doğru testleri, doğru yerlere, doğru araçlarla yazmak.

Vitest, JavaScript'te test hakkındaki düşünce şeklimi değiştirdi. Yeni kavramlar icat ettiği için değil — temeller Kent Beck onlarca yıl önce yazdığından beri değişmedi. Ama yeterince sürtünmeyi kaldırdığı için test yazmak angarya olmaktan çıkıp geliştirme döngüsünün bir parçası olmaya başladı. Test runner'ın dev sunucun kadar hızlı olup aynı config'i kullanıyorsa, bahaneler buharlaşır.

Bu yazı, ilk kurulumdan her şeyi değerli kılan felsefeye kadar Vitest hakkında bildiğim her şey.

Neden Jest Yerine Vitest#

Jest kullandıysan, Vitest'in API'sının çoğunu zaten biliyorsun. Bu kasıtlı — Vitest API seviyesinde Jest-uyumlu. describe, it, expect, beforeEach, vi.fn() — hepsi çalışır. Peki neden geçiş yapmalısın?

Native ESM Desteği#

Jest CommonJS için inşa edildi. ESM'i de halledebilir ama yapılandırma, deneysel flag'ler ve ara sıra dua gerektirir. import/export sözdizimi kullanıyorsan (ki her modern şey öyle), Jest'in transform pipeline'ıyla muhtemelen savaşmışsındır.

Vitest Vite üzerinde çalışır. Vite ESM'i doğal olarak anlar. Kaynak kodun için transform adımı yok — direkt çalışır. Bu kulağa geldiğinden daha önemli. Yıllar içinde debug ettiğim Jest sorunlarının yarısı modül çözümlemesine dayanıyor: SyntaxError: Cannot use import statement outside a module veya mock'lar modül farklı bir formatta önbelleğe alındığı için çalışmıyor.

Dev Sunucunla Aynı Config#

Projen Vite kullanıyorsa (2026'da React, Vue veya Svelte uygulaması yapıyorsan muhtemelen öyle), Vitest vite.config.ts'ini otomatik okur. Path alias'ların, plugin'lerin ve ortam değişkenlerin testlerde ek yapılandırma olmadan çalışır. Jest'te, bundler kurulumunla senkron kalması gereken paralel bir yapılandırma sürdürürsün. vite.config.ts'e her path alias eklediğinde, jest.config.ts'e karşılık gelen moduleNameMapper'ı eklemeyi hatırlamak zorundasın. Küçük bir şey ama küçük şeyler birikir.

Hız#

Vitest hızlı. Anlamlı derecede hızlı. "İki saniye kazandırır" hızlı değil — "çalışma şeklini değiştirir" hızlı. Hangi testlerin bir dosya değişikliğinden etkilendiğini anlamak için Vite'ın modül grafiğini kullanır ve sadece onları çalıştırır. Watch modu, Vite'ın dev sunucusunu anlık hissettiren aynı HMR altyapısını kullanır.

400+ testli bir projede Jest'ten Vitest'e geçmek watch-mode geri bildirim döngümüzü ~4 saniyeden 500ms'in altına indirdi. Bu "testin geçmesini bekleyeyim" ile "parmaklarım hâlâ klavyedeyken terminale göz atayım" arasındaki fark.

Yerleşik Benchmarking#

Vitest performans testi için bench()'i kutudan çıkarır. Ayrı kütüphane gerekmez:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

vitest bench ile çalıştır. Ana etkinlik değil ama benchmark.js kurup ayrı bir runner bağlamadan aynı araç zincirinde performans testi yapabilmek güzel.

Kurulum#

Yükleme#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Yapılandırma#

Proje kökünde vitest.config.ts oluştur:

typescript
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: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

Veya zaten bir vite.config.ts'in varsa genişletebilirsin:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

globals: true Kararı#

globals true olduğunda, describe, it, expect, beforeEach vb.'yi import etmene gerek yok — Jest'teki gibi her yerde kullanılabilir. false olduğunda açıkça import edersin:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

Ben globals: true kullanıyorum çünkü görsel gürültüyü azaltır ve çoğu geliştiricinin beklediğiyle uyuşur. Açık import'ları tercih eden bir takımdaysan false yap — burada yanlış cevap yok.

globals: true kullanıyorsan, TypeScript'in bunları tanıması için tsconfig.json'a Vitest tiplerini ekle:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Ortam: jsdom vs happy-dom vs node#

Vitest DOM implementasyonunu test başına veya global olarak seçmene izin verir:

  • node — DOM yok. Saf mantık, utility'ler, API route'ları ve tarayıcıya dokunmayan her şey için.
  • jsdom — Standart. Tam DOM implementasyonu. Daha ağır ama daha eksiksiz.
  • happy-dom — jsdom'dan daha hafif ve hızlı ama daha az eksiksiz. Bazı kenar durumları (Range, Selection, IntersectionObserver gibi) çalışmayabilir.

Ben global olarak jsdom kullanıp dosya bazında node'a geçiş yapıyorum:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Setup Dosyaları#

Setup dosyası her test dosyasından önce çalışır. Testing Library matcher'larını ve global mock'ları burada yapılandırırsın:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// IntersectionObserver mock — jsdom bunu implemente etmez
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// window.matchMedia mock — responsive bileşenler için gerekli
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

@testing-library/jest-dom/vitest import'u toBeInTheDocument(), toHaveClass(), toBeVisible() gibi matcher'lar verir. Bunlar DOM elemanları üzerindeki assertion'ları okunabilir ve ifadeli kılar.

İyi Test Yazmak#

AAA Kalıbı#

Her test aynı yapıyı izler: Arrange (Hazırla), Act (Uygula), Assert (Doğrula). Açık yorumlar yazmasanız bile yapı görünür olmalı:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

Hazırlama, uygulama ve doğrulamayı tek bir method çağrısı zincirinde karıştıran bir test gördüğümde, başarısız olduğunda anlaşılmasının zor olacağını biliyorum. Yorumları eklemesen bile üç fazı görsel olarak ayrı tut.

Test İsimlendirme#

İki ekol var: it('should calculate total with tax') ve it('calculates total with tax'). "should" ön eki bilgi eklemeden uzatır. Test başarısız olduğunda şunu görürsün:

FAIL  ✕ calculates total with tax

Bu zaten tam bir cümle. "should" eklemek sadece gürültü ekler. Ben doğrudan formu tercih ediyorum: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').

describe blokları için test edilen birimin adını kullan:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

Sesli oku: "calculateTotal sums item prices." "calculateTotal applies tax rate." Cümle çalışıyorsa, isimlendirme çalışıyordur.

Test Başına Tek Assertion vs Pratik Gruplama#

Saf kural test başına tek assertion der. Pratik kural der ki: test başına tek kavram. Bunlar farklı şeyler.

typescript
// Bu iyi — tek kavram, birden fazla assertion
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// Bu iyi değil — tek testte birden fazla kavram
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

İlk testte üç assertion var ama hepsi tek bir şeyi doğrular: görüntülenecek ismin formatı. Herhangi bir assertion başarısız olursa tam olarak neyin bozuk olduğunu bilirsin. İkinci test birbirine sıkıştırılmış üç ayrı test. İkinci assertion başarısız olursa oluşturmanın mı güncellemenin mi bozuk olduğunu bilemezsin ve üçüncü assertion hiç çalışmaz.

Mocking#

Mocking test araç kutundaki en güçlü ve en tehlikeli araç. İyi kullanıldığında test edilen birimi izole eder ve testleri hızlı ve deterministik yapar. Kötü kullanıldığında kod ne yaparsa yapsın geçen testler oluşturur.

vi.fn() — Mock Fonksiyonlar Oluşturma#

En basit mock, çağrılarını kaydeden bir fonksiyon:

typescript
const mockCallback = vi.fn();
 
// Çağır
mockCallback("hello", 42);
mockCallback("world");
 
// Çağrıları doğrula
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

Dönüş değeri verebilirsin:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

Veya ardışık çağrılarda farklı değerler döndürmesini sağlayabilirsin:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — Gerçek Metodları İzleme#

Davranışını değiştirmeden bir metodu gözlemlemek istediğinde:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn varsayılan olarak orijinal implementasyonu korur. Gerektiğinde .mockImplementation() ile geçersiz kılabilir ve sonra .mockRestore() ile orijinali geri yükleyebilirsin.

vi.mock() — Modül Seviyesinde Mocking#

Büyük silah bu. vi.mock() bir modülün tamamını değiştirir:

typescript
// Tüm modülü mock'la
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// Import artık mock'lanmış versiyonu kullanır
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest vi.mock() çağrılarını otomatik olarak dosyanın en üstüne taşır (hoist). Bu, mock'un herhangi bir import çalışmadan önce yerinde olması anlamına gelir. Import sırası konusunda endişelenmene gerek yok.

Otomatik Mocking#

Her export'un bir vi.fn() ile değiştirilmesini istiyorsan:

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Factory fonksiyonu olmadan Vitest tüm export'ları otomatik mock'lar. Her export edilen fonksiyon undefined döndüren bir vi.fn() olur. Bu, her fonksiyonu belirtmeden sessizleştirmek istediğin modüller (analytics veya logging gibi) için kullanışlı.

Clear vs Reset vs Restore#

Bu bir noktada herkesi şaşırtır:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — çağrı geçmişini sıfırlar, implementasyonu korur
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // hâlâ 42 döndürür
 
// mockReset — çağrı geçmişini VE implementasyonu sıfırlar
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // artık 42 döndürmüyor
 
// mockRestore — spy'lar için orijinal implementasyonu geri yükler
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() artık normal çalışır

Pratikte, testler arasında çağrı geçmişini sıfırlamak için beforeEach'te vi.clearAllMocks() kullan. spyOn kullanıyorsan ve orijinalleri geri istiyorsan vi.restoreAllMocks() kullan:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

Aşırı Mocking Tehlikesi#

Verebileceğim en önemli mocking tavsiyesi bu: her mock, testine söylediğin bir yalandır. Bir bağımlılığı mock'ladığında "Bu şeyin doğru çalıştığına güveniyorum, bu yüzden basitleştirilmiş bir versiyonla değiştireceğim" diyorsun. Varsayımın yanlışsa, test geçer ama feature bozuktur.

typescript
// Aşırı mock — yararlı bir şey test etmiyor
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

Bu test processInput'un validate ve format'ı çağırdığını doğrular. Ama ya processInput bunları yanlış sırada çağırıyorsa? Ya dönüş değerlerini yoksayıyorsa? Ya doğrulama format adımının çalışmasını engellemesi gerekiyorsa? Test bilmiyor. Tüm ilginç davranışı mock'lamışsın.

Temel kural: sınırlarda mock'la, ortada değil. Ağ isteklerini, dosya sistemi erişimini ve üçüncü taraf servisleri mock'la. Zorlayıcı bir neden olmadıkça (çalıştırmaları pahalı veya yan etkileri var gibi) kendi utility fonksiyonlarını mock'lama.

React Bileşenlerini Test Etme#

Testing Library ile Temeller#

Testing Library bir felsefe dayatır: bileşenleri kullanıcıların etkileşim kurduğu şekilde test et. İç durum kontrolü yok, bileşen instance'ı inceleme yok, shallow rendering yok. Bir bileşeni render eder ve onunla DOM üzerinden etkileşim kurarsın, tıpkı bir kullanıcının yapacağı gibi.

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Query'ler: getBy vs queryBy vs findBy#

Yeni başlayanların kafasının karıştığı yer burası. Üç query varyantı var ve her birinin belirli bir kullanım durumu var:

getBy* — Elemanı döndürür veya bulunamazsa hata fırlatır. Elemanın var olmasını beklediğinde kullan:

typescript
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — Elemanı döndürür veya bulunamazsa null. Bir şeyin MEVCUT OLMADIĞINI doğruladığında kullan:

typescript
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Promise döndürür. Asenkron olarak görünen elemanlar için kullan:

typescript
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

Erişilebilirlik Öncelikli Query'ler#

Testing Library bu query'leri kasıtlı bir öncelik sırasında sunar:

  1. getByRole — En iyi query. ARIA rollerini kullanır. Bileşenin rol ile bulunamıyorsa, bir erişilebilirlik sorunu olabilir.
  2. getByLabelText — Form elemanları için. Input'un etiketi yoksa, önce onu düzelt.
  3. getByPlaceholderText — Kabul edilebilir ama daha zayıf. Placeholder'lar kullanıcı yazdığında kaybolur.
  4. getByText — Etkileşimsiz elemanlar için. Görünür metin içeriğine göre bulur.
  5. getByTestId — Son çare. Hiçbir anlamsal query çalışmadığında kullan.
typescript
// Bunu tercih et
screen.getByRole("textbox", { name: "Email address" });
 
// Bunun yerine
screen.getByPlaceholderText("Enter your email");
 
// Ve kesinlikle bunun yerine
screen.getByTestId("email-input");

User Events#

fireEvent kullanma. @testing-library/user-event kullan. Fark önemli:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.type HER karakter için keydown, keypress, input, keyup ateşler
    // fireEvent.change sadece değeri ayarlar — gerçekçi olay akışını atlar
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent gerçek bir tarayıcının ateşleyeceği tam olay zincirini simüle eder. fireEvent.change tek bir sentetik olaydır. Bileşenin onKeyDown dinliyorsa veya onChange yerine onInput kullanıyorsa, fireEvent.change bu handler'ları tetiklemez ama userEvent.type tetikler.

Her zaman başta userEvent.setup() çağır ve döndürülen user instance'ını kullan.

Bileşen Etkileşimlerini Test Etme#

Gerçekçi bir bileşen testi şöyle görünür:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

Dikkat et: iç durum incelemesi yok, component.setState() yok, implementasyon detaylarını kontrol etmek yok. Render ediyoruz, etkileşim kuruyoruz, kullanıcının göreceği şeyi doğruluyoruz. Bileşen iç durum yönetimini useState'den useReducer'a refactor ederse, bu testler hâlâ geçer. Mesele bu.

Asenkron Kodu Test Etme#

waitFor#

Bir bileşen asenkron güncellendiğinde, waitFor assertion geçene kadar yoklama yapar:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

Fake Timer'lar#

setTimeout, setInterval veya Date kullanan kodu test ederken fake timer'lar zamanı kontrol etmeni sağlar:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Önemli: afterEach'te her zaman vi.useRealTimers() çağır. Testler arasında sızan fake timer'lar, debug edeceğin en kafa karıştırıcı başarısızlıklara neden olur.

MSW ile API Mocking#

Veri çekmeyi test etmek için Mock Service Worker (MSW) ağ isteklerini ağ seviyesinde yakalar. Bu, bileşeninin fetch/axios kodu production'daki gibi çalışır — MSW sadece ağ yanıtını değiştirir:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

MSW fetch veya axios'u doğrudan mock'lamaktan daha iyi çünkü:

  1. Bileşeninin gerçek veri çekme kodu çalışır — gerçek entegrasyonu test ediyorsun.
  2. Test bazında handler'ları override ederek hata yönetimini test edebilirsin.
  3. Aynı handler'lar hem testlerde hem tarayıcı dev modunda (Storybook mesela) çalışır.

Integration Testler#

Unit Test vs Integration Test#

Unit test tek bir fonksiyonu veya bileşeni izole eder ve geri kalan her şeyi mock'lar. Integration test birden fazla birimin birlikte çalışmasına izin verir ve sadece dış sınırları (ağ, dosya sistemi, veritabanları) mock'lar.

Gerçek şu: production'da gördüğüm bug'ların çoğu birimler arasındaki sınırlarda oluşur, içlerinde değil. Bir fonksiyon izolasyonda mükemmel çalışır ama çağıran biraz farklı formatta veri geçirdiği için başarısız olur.

Integration testler bu bug'ları yakalar. Unit testlerden daha yavaş ve başarısız olduklarında debug etmesi daha zor, ama test başına daha fazla güven verir.

Birden Fazla Bileşeni Birlikte Test Etme#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// Sadece API katmanını mock'la — geri kalan her şey gerçek
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
  });
});

Coverage#

Coverage Çalıştırma#

bash
vitest run --coverage

Bir coverage provider'a ihtiyacın var:

bash
# V8 — daha hızlı, V8'in yerleşik coverage'ını kullanır
npm install -D @vitest/coverage-v8
 
# Istanbul — daha olgun, daha fazla yapılandırma seçeneği
npm install -D @vitest/coverage-istanbul

Vitest config'inde yapılandır:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Coverage Gerçekte Ne Anlama Gelir#

Coverage sana testler sırasında hangi kod satırlarının çalıştırıldığını söyler. Bu kadar. O satırların doğru test edilip edilmediğini söylemez:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // Assertion yok!
});

Bu test divide fonksiyonunun %100 coverage'ını verir. Ayrıca kesinlikle hiçbir şey test etmez. divide null döndürse, hata fırlatsa veya füze fırlatsa test geçerdi.

Coverage yararlı bir negatif gösterge: düşük coverage kesinlikle test edilmemiş yollar olduğu anlamına gelir. Ama yüksek coverage kodunun iyi test edildiği anlamına gelmez.

Satır vs Branch Coverage#

Satır coverage en yaygın metrik ama branch coverage daha değerli:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

getDiscount({ isPremium: true, yearsActive: 10 }) ile yapılan bir test her satıra ulaşır (%100 satır coverage) ama üç branch'ten sadece ikisini test eder. isPremium: false yolu ve yearsActive <= 5 yolu test edilmemiş.

Branch coverage bunu yakalar. Koşullu mantık boyunca her olası yolu izler. Bir coverage eşiği zorunlu kılacaksan, branch coverage kullan.

Next.js Test Etme#

next/navigation Mock'lama#

useRouter, usePathname veya useSearchParams kullanan Next.js bileşenleri mock gerektirir:

typescript
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

next-intl Mock'lama#

next-intl kullanan uluslararasılaştırılmış bileşenler için:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

En basit yaklaşım — çeviriler anahtarın kendisini döndürür, yani t("hero.title") "hero.title" döndürür. Assertion'larda gerçek çevrilmiş string yerine çeviri anahtarını kontrol edersin. Bu testleri dil bağımsız yapar.

Route Handler'ları Test Etme#

Next.js Route Handler'ları Request alan ve Response döndüren normal fonksiyonlar. Test etmesi kolay:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
});

Server Action'ları Test Etme#

Server Action'lar sunucuda çalışan async fonksiyonlar. Sadece fonksiyon oldukları için doğrudan test edebilirsin:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
});

Test Felsefesi#

Davranışı Test Et, İmplementasyonu Değil#

Bu ilke o kadar önemli ki ayrı bir bölümü hak ediyor:

typescript
// İmplementasyon testi — kırılgan, refactor'larda bozulur
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Davranış testi — dayanıklı, kullanıcının gördüğünü test eder
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

İlk test useState'den useReducer'a geçiş yapsan bozulur, bileşen birebir aynı çalışsa bile. İkinci test sadece bileşenin davranışı gerçekten değişirse bozulur.

Turnusol testi basit: İmplementasyonu testi değiştirmeden refactor edebilir misin? Evetse, davranışı test ediyorsun. Hayırsa, implementasyonu test ediyorsun.

Test Kupası#

Kent C. Dodds geleneksel test piramidine alternatif olarak "Test Kupası"nı önerdi:

    ╭─────────╮
    │  E2E    │   Az — pahalı, yavaş, yüksek güven
    ├─────────┤
    │         │
    │ Integr. │   En çok — iyi güven/maliyet oranı
    │         │
    ├─────────┤
    │  Unit   │   Biraz — hızlı, odaklı, düşük maliyet
    ├─────────┤
    │ Static  │   Her zaman — TypeScript, ESLint
    ╰─────────╯

Geleneksel piramit unit testleri alta (çok sayıda) ve integration testleri ortaya (daha az) koyar. Kupa bunu tersine çevirir: integration testler tatlı nokta. Nedeni:

  • Statik analiz (TypeScript, ESLint) typo'ları, yanlış tipleri ve basit mantıksal hataları bedavaya yakalar.
  • Unit testler karmaşık saf mantık için harika ama parçaların birlikte çalışıp çalışmadığını söylemez.
  • Integration testler bileşenlerin, hook'ların ve context'lerin birlikte çalıştığını doğrular. Yazılan test başına en fazla güveni verir.
  • End-to-end testler tüm sistemi doğrular ama yavaş, güvenilmez ve bakımı pahalı.

Güveni Ne Artırır vs Neyin Zamanını Boşa Harcar#

Testler sana gönderme güveni vermek için var. Her satırın çalıştığına güven değil — uygulamanın kullanıcılar için çalıştığına güven.

Yüksek güven, yüksek değer:

  • Checkout akışının integration testi — form doğrulama, API çağrıları, durum güncellemeleri ve başarı/hata UI'ını kapsar.
  • Kenar durumlarıyla fiyat hesaplama fonksiyonunun unit testi.
  • Korumalı route'ların kimliği doğrulanmamış kullanıcıları yönlendirdiğinin testi.

Düşük güven, zaman israfı:

  • Statik pazarlama sayfasının snapshot testi — metin her değiştiğinde bozulur, anlamlı bir şey yakalamaz.
  • Bir bileşenin child'a prop geçirdiğinin unit testi — kendi kodunu değil React'i test ediyorsun.
  • useState'in çağrıldığının testi — framework'ü test ediyorsun, davranışı değil.

Her test yazmadan önce sorulacak soru: "Bu test olmasaydı production'a hangi bug kayabilirdi?" Cevap "TypeScript'in yakalayamayacağı hiçbiri" veya "kimsenin fark etmeyeceği hiçbiri" ise, test muhtemelen yazılmaya değmez.

Tasarım Geri Bildirimi Olarak Test#

Test etmesi zor kod genellikle kötü tasarlanmış koddur. Tek bir fonksiyonu test etmek için beş şeyi mock'laman gerekiyorsa, o fonksiyonun çok fazla bağımlılığı var. Ayrıntılı context provider'ları kurmadan bir bileşeni render edemiyorsan, bileşen ortamına çok fazla bağlı.

Testler kodunun bir kullanıcısı. Testlerin API'nı kullanmakta zorlanıyorsa, diğer geliştiriciler de zorlanacak. Test kurulumunla savaşırken buluyorsan, bunu daha fazla mock eklemek için değil test edilen kodu refactor etmek için bir sinyal olarak al.

Güvendiğin Testler Yaz#

Bir test suite'ine olabilecek en kötü şey boşlukları olması değil. İnsanların ona güvenmeyi bırakmasıdır. CI'da rastgele başarısız olan birkaç güvenilmez testi olan bir test suite takıma kırmızı build'leri görmezden gelmeyi öğretir. Bu olduğunda, test suite yararsızdan da kötü — aktif olarak sahte güvenlik sağlar.

Bir test aralıklı başarısız oluyorsa, düzelt veya sil. Bir test yavaşsa, hızlandır veya ayrı bir yavaş-test suite'ine taşı. Bir test her alakasız değişiklikte bozuluyorsa, implementasyon yerine davranışı test edecek şekilde yeniden yaz.

Hedef, her başarısızlığın gerçek bir şeyin bozuk olduğu anlamına geldiği bir test suite. Geliştiriciler testlere güvendiğinde, her commit'ten önce çalıştırırlar. Güvenmediğinde, --no-verify ile bypass edip parmaklarını çaprazlayarak deploy ederler.

Hafta sonuna bahse girebileceğin bir test suite inşa et. Bundan azı bakıma değmez.

İlgili Yazılar