React Bileşenlerini Test Etmek için React Testing Library Kullanımı

Bir süredir React projelerinde test yazarken Enzyme’den React Testing Library’e geçiş yapan ekiplerle çok vakit geçirdim. Bu geçişin neden bu kadar mantıklı olduğunu ve RTL’nin günlük iş akışınıza nasıl entegre edilmesi gerektiğini aktarmaya çalışacağım. Soyut kavramlardan çok, gerçek projelerden öğrendiklerimi paylaşacağım.

React Testing Library Neden Farklı?

Enzyme ile test yazarken genellikle şöyle düşünürdük: “Bu component’in iç state’i X değeri aldığında render metodu ne döndürüyor?” RTL bu bakış açısını tamamen tersine çeviriyor. Kent C. Dodds’un sıkça söylediği o cümleyi hatırlatmak isterim: “The more your tests resemble the way your software is used, the more confidence they can give you.”

RTL’nin temel felsefesi şu: kullanıcı ne görüyor, ne yapabiliyor, ne hissediyor? DOM manipülasyonu değil, kullanıcı davranışı. Component’in useState hook’unu nasıl kullandığı değil, butona tıkladığımda sayfada ne değişiyor.

Bu yaklaşım, refactoring sırasında test suite’inizin daha az kırılgan olmasını sağlıyor. Implementation detail’ları değiştirdiğinizde testleriniz sizi yanlış alarma vermez. Bu özellik büyük projelerde inanılmaz değerli.

Kurulum ve Temel Yapılandırma

Create React App ile başlayan projelerde RTL zaten kurulu geliyor. Ama sıfırdan kuruyorsanız:

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

jest.config.js veya package.json içinde setup dosyanızı tanımlayın:

# package.json içinde
{
  "jest": {
    "setupFilesAfterFramework": ["@testing-library/jest-dom"]
  }
}

src/setupTests.js dosyasına şunu ekleyin:

import '@testing-library/jest-dom';

Bu satır sayesinde toBeInTheDocument(), toHaveTextContent(), toBeVisible() gibi matcher’ları kullanabilirsiniz. Bu matcher’lar olmadan testler çok daha verbose ve okunaksız hale geliyor.

İlk Gerçek Senaryo: Login Formu

Teoriden çıkıp gerçeğe geçelim. Bir login formu düşünün, çoğu projede bu ilk karşılaşılan test senaryosu:

// LoginForm.jsx
import React, { useState } from 'react';

const LoginForm = ({ onSubmit }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!email || !password) {
      setError('Email ve şifre zorunludur.');
      return;
    }
    try {
      await onSubmit({ email, password });
    } catch (err) {
      setError('Giriş başarısız. Bilgilerinizi kontrol edin.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p role="alert">{error}</p>}
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <label htmlFor="password">Şifre</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Giriş Yap</button>
    </form>
  );
};

export default LoginForm;

Şimdi bu component için test dosyasını yazalım:

// LoginForm.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  test('boş form gönderildiğinde hata mesajı gösterilmeli', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const submitButton = screen.getByRole('button', { name: /giriş yap/i });
    await user.click(submitButton);
    
    expect(screen.getByRole('alert')).toHaveTextContent(
      'Email ve şifre zorunludur.'
    );
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  test('geçerli bilgilerle form gönderildiğinde onSubmit çağrılmalı', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn().mockResolvedValue(undefined);
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/şifre/i), 'gizlisifre123');
    await user.click(screen.getByRole('button', { name: /giriş yap/i }));
    
    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'gizlisifre123',
    });
  });

  test('API hatası durumunda kullanıcıya hata gösterilmeli', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn().mockRejectedValue(new Error('Unauthorized'));
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/şifre/i), 'yanliksifre');
    await user.click(screen.getByRole('button', { name: /giriş yap/i }));
    
    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Giriş başarısız.'
    );
  });
});

Dikkat edin, screen.findByRole('alert') kullanıyoruz çünkü asenkron bir işlem var. findBy query’leri otomatik olarak element görünene kadar bekler.

Query Seçimi: Öncelik Sırası

RTL’de birden fazla query tipi var ve hangisini ne zaman kullanacağınızı bilmek kritik. Yanlış query seçimi testleri hem kırılgan yapar hem de yanlış şeyleri test etmiş olursunuz.

Tercih sırası şöyle olmalı:

  • getByRole: Erişilebilirlik rollerine göre sorgular. Button, input, heading, link gibi. En tercih edileni bu.
  • getByLabelText: Form elemanları için, label ile ilişkilendirilmiş input’ları bulur.
  • getByPlaceholderText: Placeholder varsa kullanılabilir ama label daha iyidir.
  • getByText: Görünür metin içeriğine göre bulur. Butonlar, paragraflar için.
  • getByDisplayValue: Mevcut değeriyle dolu input’lar için.
  • getByAltText: Görseller için alt text ile sorgular.
  • getByTitle: Title attribute’u ile sorgular.
  • getByTestId: Son çare. data-testid attribute’u ile bulur. Implementation detail’a bağımlılık yaratır.

data-testid kullanmaktan kaçının. Her test için data-testid eklemek, production kodunu test için kirletmek anlamına gelir. Accessibility attribute’ları hem kullanıcılara hem testlere hizmet eder.

Asenkron İşlemler ve waitFor

Gerçek dünya uygulamalarında çoğu şey asenkron. API çağrıları, zamanlayıcılar, animasyonlar. RTL’nin asenkron yardımcılarını iyi anlamak gerekiyor.

// ProductList.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import ProductList from './ProductList';

const server = setupServer(
  rest.get('/api/products', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'Laptop', price: 15000 },
        { id: 2, name: 'Klavye', price: 850 },
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('ürün listesi API'den yüklenmeli', async () => {
  render(<ProductList />);
  
  // Önce loading state kontrol edilebilir
  expect(screen.getByText(/yükleniyor/i)).toBeInTheDocument();
  
  // Ürünlerin görünmesini bekle
  expect(await screen.findByText('Laptop')).toBeInTheDocument();
  expect(screen.getByText('Klavye')).toBeInTheDocument();
  
  // Loading state'in kaybolduğunu kontrol et
  expect(screen.queryByText(/yükleniyor/i)).not.toBeInTheDocument();
});

test('API hatası durumunda kullanıcıya mesaj gösterilmeli', async () => {
  server.use(
    rest.get('/api/products', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<ProductList />);
  
  await waitFor(() => {
    expect(screen.getByText(/ürünler yüklenemedi/i)).toBeInTheDocument();
  });
});

MSW (Mock Service Worker) kullanımı burada kritik. Axios mock’u veya fetch mock’u yazmak yerine network layer’ı gerçekçi biçimde mock’lamak, testlerinizi gerçek kullanım senaryolarına çok daha yakın tutar.

Context ve Provider’larla Çalışmak

Redux veya Context API kullanan component’leri test ederken custom render fonksiyonu oluşturmak çok işe yarıyor:

// test-utils.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';

const AllProviders = ({ children }) => {
  return (
    <BrowserRouter>
      <AuthProvider>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </AuthProvider>
    </BrowserRouter>
  );
};

const customRender = (ui, options) =>
  render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

Bu utility dosyasını test dosyalarında @testing-library/react yerine import edersiniz:

// Dashboard.test.jsx
import { render, screen } from '../test-utils';
import userEvent from '@testing-library/user-event';
import Dashboard from './Dashboard';

test('oturum açmış kullanıcı dashboard'ı görebilmeli', async () => {
  render(<Dashboard />, {
    wrapper: ({ children }) => (
      <MockAuthProvider user={{ name: 'Ahmet', role: 'admin' }}>
        {children}
      </MockAuthProvider>
    )
  });
  
  expect(screen.getByText(/hoş geldin, ahmet/i)).toBeInTheDocument();
  expect(screen.getByRole('navigation')).toBeInTheDocument();
});

Custom Hook Testleri

Component’lerin yanı sıra custom hook’ları da test etmek gerekiyor. renderHook burada devreye giriyor:

// useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

jest.useFakeTimers();

test('değer belirtilen süre sonra güncellenmeli', () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'ilk', delay: 500 } }
  );
  
  expect(result.current).toBe('ilk');
  
  rerender({ value: 'yeni deger', delay: 500 });
  
  // Süre dolmadan değer değişmemeli
  expect(result.current).toBe('ilk');
  
  act(() => {
    jest.advanceTimersByTime(500);
  });
  
  expect(result.current).toBe('yeni deger');
});

Custom hook testleri genellikle gözden kaçıyor ama karmaşık iş mantığını hook’lara taşıdığınızda bu testler kritik hale geliyor. useForm, usePagination, useInfiniteScroll gibi hook’lar ayrı ayrı test edilmeli.

Snapshot Testleri ve RTL

Snapshot testleri tartışmalı bir konu. RTL ekibi genel olarak snapshot yerine explicit assertion’ları tercih etmenizi öneriyor. Ama bazı durumlarda snapshot kullanmak mantıklı olabilir, özellikle UI component kütüphanesi geliştiriyorsanız:

// Button.test.jsx
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';

test('primary button doğru render edilmeli', () => {
  const { container } = render(
    <Button variant="primary" disabled={false}>
      Kaydet
    </Button>
  );
  
  expect(container.firstChild).toMatchSnapshot();
});

// Ancak bunu tercih edin:
test('primary button doğru class ile render edilmeli', () => {
  render(<Button variant="primary">Kaydet</Button>);
  
  const button = screen.getByRole('button', { name: /kaydet/i });
  expect(button).toHaveClass('btn-primary');
  expect(button).not.toBeDisabled();
});

İkinci yaklaşım daha açıklayıcı ve değişiklik toleransı daha yüksek. Snapshot’lar kolayca “güncellenip geçilir” hale gelir, oysa explicit assertion’lar gerçekten neyin test edildiğini gösterir.

Sık Yapılan Hatalar

Deneyimlerime göre RTL’ye geçen ekiplerin en sık düştüğü tuzaklar şunlar:

act() uyarılarını görmezden gelmek: Bu uyarılar “state güncelleme test dışında gerçekleşti” anlamına gelir. userEvent ve findBy query’leri çoğu durumu halleder ama bazen açık act() sarması gerekebilir.

getBy ile asenkron test yazmak: getBy anında sonuç arar, bulamazsa patlar. Asenkron durumlar için her zaman findBy kullanın veya waitFor içine alın.

waitFor içinde yan etki üretmek: waitFor callback’ini birden fazla kez çalıştırabilir. İçine userEvent eylemleri koymayın, sadece assertion koyun.

Her şeye data-testid eklemek: Projenin başında bu alışkanlık edinen ekipler zamanla accessibility sorunlarıyla boğuşur. getByRole ve getByLabelText accessibility’yi de aynı anda test eder.

CI/CD Entegrasyonu

Test suite’ini CI pipeline’ına entegre ederken dikkat edilmesi gereken noktalar var. GitHub Actions için tipik bir yapılandırma:

# .github/workflows/test.yml
name: Test Suite

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage --watchAll=false --ci
      - uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

--ci flag’i önemli: bu mod test’leri non-interactive olarak çalıştırır ve snapshot güncellemelerini otomatik yapmaz. PR’larda beklenmedik snapshot değişikliklerini yakalamanızı sağlar.

Coverage raporunda hangi threshold’ları belirleyeceğiniz tartışmalı. %80 statement coverage iyi bir başlangıç noktası ama kritik iş mantığı içeren modüller için %90-95 hedefleyin. Her şeyi %100 coverage için test etmeye çalışmak genellikle değersiz testlerin çoğalmasına yol açar.

Performans: Testleri Hızlı Tutmak

Proje büyüdükçe test süresi de uzar. Bir e-ticaret projesinde 400 test dosyası 8 dakikada çalışıyordu. Birkaç optimizasyonla bunu 2.5 dakikaya indirdik:

  • Jest’in worker sayısını ayarlayın: --maxWorkers=50% CI ortamında genellikle daha stabil sonuç verir.
  • MSW yerine doğrudan fetch mock kullanın hız önemli olduğunda: Ama bu bir trade-off, gerçekçilik azalır.
  • Test dosyalarını küçük tutun: Bir test dosyasında 50 test varsa bölün. Jest paralel çalıştırabilmek için dosya sınırlarını kullanır.
  • beforeAll ve afterAll doğru kullanın: Her test için server kurulumu yapmak yerine beforeAll ile bir kez kurun.

Sonuç

React Testing Library ile test yazmak başlangıçta Enzyme’den farklı düşünmeyi gerektiriyor. “Bu component nasıl çalışıyor?” sorusu yerine “Kullanıcı bu component ile nasıl etkileşim kuruyor?” sorusunu sormayı öğrenmek biraz zaman alıyor.

Ama bu zihinsel dönüşümün karşılığı büyük. Refactoring sırasında kırılmayan testler, accessibility sorunlarını erken yakalama, daha az test bakım yükü. Özellikle büyük ekiplerde “bu test neden başarısız oldu?” sorusuyla geçirilen zamanın dramatik biçimde azaldığını görüyorsunuz.

Bir tavsiye: Projenize RTL eklerken mevcut Enzyme testlerini bir anda dönüştürmeye çalışmayın. Yeni feature’lar için RTL ile yazın, eski testler zamanla doğal olarak dönüşür. En kritik kullanıcı akışlarından başlayın; login, ödeme, form submission gibi iş değeri yüksek senaryolar. Zamanla doğru sorular sormayı içselleştirdikçe RTL gerçekten keyifli hale geliyor.

Bir yanıt yazın

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