MSW ile API Mocklama ve Frontend Test Stratejisi

Frontend geliştirme dünyasında en çok zaman kaybettiren şeylerden biri şüphesiz backend’in hazır olmasını beklemektir. Özellikle büyük ekiplerde frontend ve backend paralel yürürken, bir API endpoint’i henüz yazılmamışken UI tarafını test etmeye çalışmak gerçekten sinir bozucu olabiliyor. İşte tam burada Mock Service Worker (MSW) devreye giriyor ve hayatı ciddi ölçüde kolaylaştırıyor.

Ben bu araçla yaklaşık iki yıl önce tanıştım. O dönemde ekibimiz React tabanlı bir dashboard projesi geliştiriyordu ve backend takımı sürekli geride kalıyordu. Hardcoded test verileri yazmaktan, if (isDev) bloklarıyla kodu kirletmekten bıkmıştım. MSW’yi denedikten sonra bir daha o eski yöntemlere dönmek istemedim.

MSW Nedir ve Neden Farklıdır?

MSW, Service Worker API’sini kullanarak ağ katmanında HTTP isteklerini yakalar. Bu yaklaşımın güzelliği şu: uygulamanızın kodu gerçek bir API ile mi konuştuğunu yoksa mock ile mi konuştuğunu bilmiyor. fetch, axios, XMLHttpRequest fark etmeksizin tüm istekler yakalanıyor.

Rakip yaklaşımlarla karşılaştırdığınızda farkı net görürsünüz:

  • json-server: Gerçek bir HTTP sunucusu ayağa kaldırır, port yönetimi gerektirir ve REST dışı senaryolarda zorlanır
  • Axios mock adapter: Sadece axios kullananlar için geçerlidir, kütüphane değiştiğinde her şeyi yeniden yazarsınız
  • Manuel fetch mock: jest.mock('node-fetch') gibi çözümler kırılgandır ve gerçek ağ davranışını simüle etmez
  • MSW: Gerçek ağ katmanında çalışır, tarayıcı ve Node.js ortamında tutarlı davranır, test ve geliştirme modunda aynı handler’ları kullanabilirsiniz

Kurulum ve İlk Yapılandırma

Önce paketi kuralım:

npm install msw --save-dev
# veya
yarn add msw --dev

Browser ortamı için service worker dosyasını oluşturmamız gerekiyor:

npx msw init public/ --save

Bu komut public/mockServiceWorker.js dosyasını oluşturur. Bu dosyayı .gitignore‘a eklemeyin, versiyon kontrolüne dahil edin. Bazı ekipler bunu atladığı için CI/CD’de garip hatalar alıyor.

Şimdi handler’larımızı tanımlayalım. Proje yapısında src/mocks/ klasörü oluşturun:

mkdir -p src/mocks
touch src/mocks/handlers.ts
touch src/mocks/browser.ts
touch src/mocks/server.ts

handlers.ts dosyası hem browser hem de Node.js (Jest) ortamında kullanılacak:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
}

const mockUsers: User[] = [
  { id: 1, name: 'Ahmet Yılmaz', email: '[email protected]', role: 'admin' },
  { id: 2, name: 'Zeynep Kaya', email: '[email protected]', role: 'editor' },
  { id: 3, name: 'Murat Demir', email: '[email protected]', role: 'viewer' },
]

export const handlers = [
  // Kullanıcı listesi
  http.get('/api/users', () => {
    return HttpResponse.json(mockUsers)
  }),

  // Tekil kullanıcı
  http.get('/api/users/:id', ({ params }) => {
    const user = mockUsers.find(u => u.id === Number(params.id))
    
    if (!user) {
      return HttpResponse.json(
        { error: 'Kullanıcı bulunamadı' },
        { status: 404 }
      )
    }
    
    return HttpResponse.json(user)
  }),

  // Kullanıcı güncelleme
  http.put('/api/users/:id', async ({ request, params }) => {
    const body = await request.json() as Partial<User>
    const userIndex = mockUsers.findIndex(u => u.id === Number(params.id))
    
    if (userIndex === -1) {
      return HttpResponse.json({ error: 'Kullanıcı bulunamadı' }, { status: 404 })
    }
    
    mockUsers[userIndex] = { ...mockUsers[userIndex], ...body }
    return HttpResponse.json(mockUsers[userIndex])
  }),
]

Browser entegrasyonu için:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

Node.js/Jest ortamı için ayrı setup:

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Geliştirme Ortamında Aktivasyon

main.tsx veya index.tsx dosyanızda koşullu olarak worker’ı başlatın:

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  // Ortam değişkeni ile kontrol edebilirsiniz
  if (process.env.VITE_ENABLE_MOCK !== 'true') {
    return
  }

  const { worker } = await import('./mocks/browser')
  
  return worker.start({
    onUnhandledRequest: 'warn', // Mock edilmemiş istekleri konsola yaz
  })
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

.env.development dosyasına ekleyin:

VITE_ENABLE_MOCK=true

Bu yapıyı kurduğunuzda, tarayıcı konsolunda şunu görürsünüz:

[MSW] Mocking enabled.

Artık uygulamanız gerçek API yerine tanımladığınız handler’lara istek atıyor.

Jest ile Entegrasyon: Test Ortamı Kurulumu

Test tarafı biraz daha dikkat gerektiriyor. jest.setup.ts dosyanızı oluşturun veya düzenleyin:

// jest.setup.ts
import { server } from './src/mocks/server'

// Her test suite başlamadan önce sunucuyu başlat
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))

// Her testten sonra handler override'larını temizle
afterEach(() => server.resetHandlers())

// Tüm testler bittikten sonra kapat
afterAll(() => server.close())

jest.config.ts dosyanızda bu setup dosyasını referans gösterin:

// jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['./jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
}

export default config

Gerçek Dünya Test Senaryoları

Şimdi asıl konuya gelelim. Teorik kurulumdan ziyade gerçekte nasıl test yazıyoruz, bunu görelim.

Bir kullanıcı listesi bileşenimiz olduğunu varsayalım:

// src/components/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { UserList } from './UserList'

describe('UserList Bileşeni', () => {
  test('kullanıcıları başarıyla listeler', async () => {
    render(<UserList />)
    
    // Loading state'i kontrol et
    expect(screen.getByText(/yükleniyor/i)).toBeInTheDocument()
    
    // API cevabını bekle
    await waitFor(() => {
      expect(screen.getByText('Ahmet Yılmaz')).toBeInTheDocument()
    })
    
    expect(screen.getByText('Zeynep Kaya')).toBeInTheDocument()
    expect(screen.getByText('Murat Demir')).toBeInTheDocument()
  })

  test('API hatası durumunda hata mesajı gösterir', async () => {
    // Bu test için handler'ı geçici olarak override et
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json(
          { error: 'Sunucu hatası' },
          { status: 500 }
        )
      })
    )
    
    render(<UserList />)
    
    await waitFor(() => {
      expect(screen.getByText(/bir hata oluştu/i)).toBeInTheDocument()
    })
  })

  test('boş liste durumunu doğru işler', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json([])
      })
    )
    
    render(<UserList />)
    
    await waitFor(() => {
      expect(screen.getByText(/kullanıcı bulunamadı/i)).toBeInTheDocument()
    })
  })
})

Burada kritik nokta: server.use() ile override ettiğiniz handler’lar sadece o test için geçerli. afterEach(() => server.resetHandlers()) satırı sayesinde bir sonraki test temiz bir slate ile başlıyor.

Ağ Gecikmesi ve Yükleme Durumları Testi

Gerçek hayatta loading state’leri test etmek zordur çünkü mock yanıtlar çok hızlı döner. MSW bunu da çözmüş:

// src/mocks/handlers.ts içinde ya da test dosyasında
import { delay } from 'msw'

// Yavaş ağ simülasyonu
http.get('/api/users', async () => {
  await delay(1500) // 1.5 saniye gecikme
  return HttpResponse.json(mockUsers)
})

// Test içinde
test('yavaş ağda skeleton loader gösterir', async () => {
  server.use(
    http.get('/api/users', async () => {
      await delay(2000)
      return HttpResponse.json(mockUsers)
    })
  )
  
  render(<UserList />)
  
  // Hemen skeleton görünmeli
  expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument()
  
  // 2 saniye sonra gerçek içerik gelmeli
  await waitFor(
    () => expect(screen.getByText('Ahmet Yılmaz')).toBeInTheDocument(),
    { timeout: 3000 }
  )
})

Kimlik Doğrulama Senaryoları

Authentication akışlarını test etmek MSW ile gerçekten kolay hale geliyor. Özellikle token yönetimi ve 401 handling:

// src/mocks/handlers.ts
const AUTH_TOKEN = 'gecerli-test-token-12345'

handlers.push(
  http.post('/api/auth/login', async ({ request }) => {
    const { email, password } = await request.json() as { email: string; password: string }
    
    if (email === '[email protected]' && password === 'dogru-sifre') {
      return HttpResponse.json({
        token: AUTH_TOKEN,
        user: { id: 1, name: 'Test Kullanıcı', email }
      })
    }
    
    return HttpResponse.json(
      { error: 'Geçersiz kimlik bilgileri' },
      { status: 401 }
    )
  }),

  http.get('/api/profile', ({ request }) => {
    const authHeader = request.headers.get('Authorization')
    
    if (!authHeader || authHeader !== `Bearer ${AUTH_TOKEN}`) {
      return HttpResponse.json(
        { error: 'Yetkisiz erişim' },
        { status: 401 }
      )
    }
    
    return HttpResponse.json({
      id: 1,
      name: 'Test Kullanıcı',
      email: '[email protected]'
    })
  })
)
// Login formu testi
describe('Login Akışı', () => {
  test('başarılı giriş sonrası dashboard'a yönlendirir', async () => {
    const user = userEvent.setup()
    render(<LoginPage />)
    
    await user.type(screen.getByLabelText(/e-posta/i), '[email protected]')
    await user.type(screen.getByLabelText(/şifre/i), 'dogru-sifre')
    await user.click(screen.getByRole('button', { name: /giriş yap/i }))
    
    await waitFor(() => {
      expect(screen.getByText(/hoş geldiniz/i)).toBeInTheDocument()
    })
  })

  test('yanlış şifre ile hata mesajı gösterir', async () => {
    const user = userEvent.setup()
    render(<LoginPage />)
    
    await user.type(screen.getByLabelText(/e-posta/i), '[email protected]')
    await user.type(screen.getByLabelText(/şifre/i), 'yanlis-sifre')
    await user.click(screen.getByRole('button', { name: /giriş yap/i }))
    
    await waitFor(() => {
      expect(screen.getByText(/geçersiz kimlik bilgileri/i)).toBeInTheDocument()
    })
  })
})

Dosya Yükleme ve FormData Testleri

Bir konuda çok soru alıyorum: dosya yükleme işlemlerini nasıl test edeceğiz? MSW burada da imdada yetişiyor:

// Handler tarafında
http.post('/api/upload', async ({ request }) => {
  const formData = await request.formData()
  const file = formData.get('file') as File
  
  if (!file) {
    return HttpResponse.json({ error: 'Dosya bulunamadı' }, { status: 400 })
  }
  
  if (file.size > 5 * 1024 * 1024) { // 5MB limit
    return HttpResponse.json(
      { error: 'Dosya boyutu çok büyük' },
      { status: 413 }
    )
  }
  
  return HttpResponse.json({
    id: 'dosya-123',
    name: file.name,
    size: file.size,
    url: `https://cdn.sirket.com/uploads/${file.name}`
  })
})
// Test tarafında
test('dosya yükleme başarılı senaryosu', async () => {
  const user = userEvent.setup()
  render(<FileUpload />)
  
  const file = new File(['merhaba dünya'], 'test.txt', { type: 'text/plain' })
  const input = screen.getByLabelText(/dosya seç/i)
  
  await user.upload(input, file)
  await user.click(screen.getByRole('button', { name: /yükle/i }))
  
  await waitFor(() => {
    expect(screen.getByText(/başarıyla yüklendi/i)).toBeInTheDocument()
  })
})

Passthrough: Gerçek API ile Karışık Çalışma

Bazen bazı endpoint’leri mock’larken diğerlerinin gerçek API’ye gitmesini isteyebilirsiniz. Özellikle hybrid geliştirme aşamasında bu çok işe yarıyor:

// src/mocks/browser.ts
import { setupWorker, passthrough } from 'msw/browser'
import { handlers } from './handlers'
import { http } from 'msw'

// Statik varlıkları ve analytics'i geç
const passthroughHandlers = [
  http.get('/static/*', () => passthrough()),
  http.post('https://analytics.google.com/*', () => passthrough()),
]

export const worker = setupWorker(...handlers, ...passthroughHandlers)

Bu şekilde Analytics çağrıları gerçek servise giderken API çağrılarınız mock’tan besleniyor. CI ortamında gereksiz dış bağımlılık yaratmamak için bu oldukça değerli bir özellik.

CI/CD Pipeline’ında MSW

GitHub Actions veya GitLab CI’da testleri çalıştırırken dikkat etmeniz gereken birkaç nokta var:

# .github/workflows/test.yml
name: Frontend Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Node.js Kurulum
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Bağımlılıkları Yükle
        run: npm ci
      
      - name: Testleri Çalıştır
        run: npm test -- --coverage --watchAll=false
        env:
          CI: true
          NODE_ENV: test

CI ortamında onUnhandledRequest: 'error' ayarı çok önemli. Test suite’inizde mock’lamayı unuttuğunuz bir endpoint varsa test hemen fail olur, sessizce geçmez. Bu davranışı jest.setup.ts‘de zaten tanımlamıştık.

Handler Organizasyonu: Büyüyen Projelerde Düzen

Proje büyüdükçe tek bir handlers.ts dosyası şişmeye başlar. Modüler yapıya geçin:

src/mocks/
  handlers/
    auth.handlers.ts
    users.handlers.ts
    products.handlers.ts
    orders.handlers.ts
  index.ts
  browser.ts
  server.ts
// src/mocks/handlers/index.ts
import { authHandlers } from './auth.handlers'
import { userHandlers } from './users.handlers'
import { productHandlers } from './products.handlers'
import { orderHandlers } from './orders.handlers'

export const handlers = [
  ...authHandlers,
  ...userHandlers,
  ...productHandlers,
  ...orderHandlers,
]

Her domain kendi mock verisi ve handler’larıyla kendi dosyasında yaşıyor. Yeni bir ekip üyesi geldiğinde nereye bakacağını hemen anlıyor.

Sonuç

MSW, frontend test stratejisinde gerçek bir paradigma değişikliği sunuyor. Ağ katmanında çalışması sayesinde uygulamanız mock’un farkında değil ve bu da testlerinizin gerçek kullanım senaryolarını birebir yansıtmasını sağlıyor.

Benim pratik önerilerim şunlar:

  • Geliştirme ve test ortamında aynı handler’ları kullanın. Kod tekrarından kaçının.
  • onUnhandledRequest: 'error' ile başlayın. Uyarı yerine hata almak sizi disiplinli tutar.
  • Handler’larınızı domain’lere göre organize edin. Dosya büyüdüğünde pişman olursunuz.
  • Gecikme simülasyonunu ihmal etmeyin. Loading state’leri test etmezseniz production’da sürpriz yaşarsınız.
  • Mock verilerinizi tip güvenli yapın. TypeScript interface’leri kullanmak, backend API değişikliklerini erken yakalamanızı sağlar.

MSW’nin en değerli yanı şu: bir gün backend hazır olduğunda handler’larınızı kapatıyorsunuz ve uygulamanız sorunsuz çalışıyor. Geçiş sırasında tek satır uygulama kodu değiştirmeniz gerekmiyor. Bu, gerçek anlamda test edilebilir frontend mimarisinin en güzel göstergesi.

Bir yanıt yazın

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