Supertest ile Node.js REST API Test Yazımı
Production ortamında bir REST API patladığında, “acaba testler var mıydı?” sorusu hep aklıma gelir. Çoğu zaman cevap ya “vardı ama çalışmıyordu” ya da “yoktu, zaten vakit yoktu” oluyor. Node.js projelerinde Supertest kütüphanesini düzgün kullanmayı öğrendikten sonra bu mazeretlerin büyük bir kısmının ortadan kalktığını gördüm. Supertest, HTTP assertion’larını o kadar sade bir hale getiriyor ki test yazmak zahmetli bir iş olmaktan çıkıyor.
Supertest Nedir ve Neden Kullanmalısınız
Supertest, Node.js HTTP sunucularını test etmek için kullanılan bir kütüphane. Temel mantığı şu: Express veya benzeri bir framework ile yazdığınız uygulamayı gerçekten bir port üzerinde ayağa kaldırmadan, doğrudan HTTP istekleri göndererek test edebiliyorsunuz. Bu yaklaşım entegrasyon testleri için biçilmiş kaftan.
Jest veya Mocha ile birlikte kullanıldığında çok güçlü bir test altyapısı oluşturuyor. Ben genellikle Jest tercih ediyorum çünkü mock sistemi ve snapshot özellikleri işleri kolaylaştırıyor, ama Mocha + Chai kombinasyonunu kullananlar da Supertest’i sorunsuz entegre edebilir.
Şu soru akla gelebilir: “Unit test yeterli değil mi?” Hayır, yeterli değil. Unit test iş mantığını test eder, ama HTTP katmanındaki middleware’ler, route tanımları, hata yönetimi ve response format tutarlılığı ancak entegrasyon testleriyle yakalanır.
Kurulum ve Proje Yapısı
Önce bağımlılıkları kuralım:
npm install --save-dev supertest jest @types/supertest
npm install express
Proje yapısı olarak şunu tavsiye ediyorum:
mkdir -p src/{routes,middleware,controllers}
mkdir -p tests/{integration,unit}
touch src/app.js src/server.js
touch tests/integration/users.test.js
Burada önemli bir nokta var: app.js ile server.js dosyalarını birbirinden ayırın. app.js Express uygulamasını dışa aktarır, server.js ise listen() çağrısını yapar. Supertest, listen() çağrısını kendisi yönetiyor, bu yüzden bu ayrım testlerin port çakışması olmadan çalışmasını sağlar.
# src/app.js
const express = require('express');
const app = express();
app.use(express.json());
// Route'ları buraya bağlıyoruz
const userRoutes = require('./routes/users');
app.use('/api/users', userRoutes);
module.exports = app;
# src/server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Sunucu ${PORT} portunda çalışıyor`);
});
İlk Testleri Yazmak
Basit bir kullanıcı API’si üzerinden gidelim. Önce test edeceğimiz route’u oluşturalım:
# src/routes/users.js
const express = require('express');
const router = express.Router();
const users = [
{ id: 1, name: 'Ahmet Yılmaz', email: '[email protected]', role: 'admin' },
{ id: 2, name: 'Fatma Kaya', email: '[email protected]', role: 'user' },
];
router.get('/', (req, res) => {
const { role } = req.query;
if (role) {
const filtered = users.filter(u => u.role === role);
return res.json({ success: true, data: filtered, count: filtered.length });
}
res.json({ success: true, data: users, count: users.length });
});
router.get('/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ success: false, message: 'Kullanıcı bulunamadı' });
}
res.json({ success: true, data: user });
});
router.post('/', (req, res) => {
const { name, email, role } = req.body;
if (!name || !email) {
return res.status(400).json({
success: false,
message: 'İsim ve email zorunludur'
});
}
const newUser = {
id: users.length + 1,
name,
email,
role: role || 'user'
};
users.push(newUser);
res.status(201).json({ success: true, data: newUser });
});
module.exports = router;
Şimdi bu route için testleri yazalım:
# tests/integration/users.test.js
const request = require('supertest');
const app = require('../../src/app');
describe('GET /api/users', () => {
it('tüm kullanıcıları döndürmeli', async () => {
const response = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.success).toBe(true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.count).toBeGreaterThan(0);
});
it('role parametresiyle filtreleme yapmalı', async () => {
const response = await request(app)
.get('/api/users?role=admin')
.expect(200);
expect(response.body.data.every(u => u.role === 'admin')).toBe(true);
});
it('var olmayan kullanıcı için 404 dönmeli', async () => {
const response = await request(app)
.get('/api/users/9999')
.expect(404);
expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Kullanıcı bulunamadı');
});
});
describe('POST /api/users', () => {
it('geçerli veriyle yeni kullanıcı oluşturmalı', async () => {
const newUser = {
name: 'Mehmet Demir',
email: '[email protected]',
role: 'user'
};
const response = await request(app)
.post('/api/users')
.send(newUser)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data.name).toBe(newUser.name);
expect(response.body.data.id).toBeDefined();
});
it('eksik alan varsa 400 dönmeli', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Sadece İsim' })
.expect(400);
expect(response.body.success).toBe(false);
});
});
Kimlik Doğrulama Gerektiren Endpoint’leri Test Etmek
Gerçek projelerde kimlik doğrulama her zaman var. JWT token gerektiren bir endpoint’i nasıl test ederiz?
# tests/integration/auth.test.js
const request = require('supertest');
const app = require('../../src/app');
describe('Korumalı Endpoint Testleri', () => {
let authToken;
// Her test grubundan önce login olup token alıyoruz
beforeAll(async () => {
const loginResponse = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'gizli123'
})
.expect(200);
authToken = loginResponse.body.token;
});
it('token olmadan korumalı rotaya erişim reddedilmeli', async () => {
await request(app)
.get('/api/profile')
.expect(401);
});
it('geçerli token ile profil bilgisi alınmalı', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.data).toHaveProperty('email');
});
it('geçersiz token reddedilmeli', async () => {
await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer yanlis.token.degeri')
.expect(401);
});
});
beforeAll ile token bir kez alınıyor ve tüm testlerde paylaşılıyor. Bu sayede gereksiz login isteği yapmıyoruz. beforeEach kullanmak her test için yeni token almak demek, bu hem yavaş hem de gereksiz.
Veritabanı Bağımlılığını Yönetmek
Testlerin veritabanına bağımlı olması büyük bir sorun. CI/CD pipeline’ında gerçek veritabanı olmayabilir veya test verileri tutarsız olabilir. İki yaklaşım var:
Birinci yaklaşım: Test veritabanı kullanmak. .env.test dosyası oluşturup orada ayrı bir bağlantı dizisi tanımlayın.
İkinci yaklaşım: Veritabanı katmanını mock’lamak. Bu daha hızlı ama bakımı daha zor.
Ben karma bir strateji kullanıyorum: Geliştirme sırasında mock, CI’da gerçek test veritabanı. Jest’in mock sistemiyle veritabanı modülünü nasıl taklit edeceğimizi görelim:
# tests/integration/products.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db');
// Veritabanı modülünü tamamen mock'luyoruz
jest.mock('../../src/db');
describe('Ürün API Testleri', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('veritabanından ürünleri çekip döndürmeli', async () => {
const mockUrunler = [
{ id: 1, ad: 'Laptop', fiyat: 15000 },
{ id: 2, ad: 'Mouse', fiyat: 350 }
];
// db.query metodunu sahte veriyle yanıt verecek şekilde ayarlıyoruz
db.query.mockResolvedValue({ rows: mockUrunler });
const response = await request(app)
.get('/api/products')
.expect(200);
expect(response.body.data).toHaveLength(2);
expect(db.query).toHaveBeenCalledTimes(1);
});
it('veritabanı hatası durumunda 500 dönmeli', async () => {
db.query.mockRejectedValue(new Error('Bağlantı zaman aşımına uğradı'));
await request(app)
.get('/api/products')
.expect(500);
});
});
Burada dikkat edilmesi gereken şey: mockRejectedValue ile hata senaryolarını test etmek. Production’da en çok kaçırılan şey hata yönetimi testleri. Veritabanı düştüğünde API nasıl davranıyor? Bu sorunun cevabı test dosyalarında olmalı.
Dosya Yükleme Endpoint’lerini Test Etmek
Multipart form data gönderen endpoint’ler biraz farklı ele alınıyor. Supertest’in attach metodunu kullanıyoruz:
# tests/integration/upload.test.js
const request = require('supertest');
const path = require('path');
const app = require('../../src/app');
describe('Dosya Yükleme Testleri', () => {
it('geçerli resim dosyası yüklenmeli', async () => {
const testDosyaYolu = path.join(__dirname, '../fixtures/test-resim.jpg');
const response = await request(app)
.post('/api/upload')
.attach('dosya', testDosyaYolu)
.field('aciklama', 'Test resmi')
.set('Authorization', `Bearer ${global.testToken}`)
.expect(200);
expect(response.body.url).toMatch(/^https?:///);
expect(response.body.dosyaAdi).toBeTruthy();
});
it('izin verilmeyen dosya türü reddedilmeli', async () => {
const testDosyaYolu = path.join(__dirname, '../fixtures/test-belge.exe');
await request(app)
.post('/api/upload')
.attach('dosya', testDosyaYolu)
.set('Authorization', `Bearer ${global.testToken}`)
.expect(415);
});
});
Test fixture’ları için tests/fixtures klasörü açın ve küçük boyutlu gerçek test dosyaları koyun. Büyük dosyalar test süresini uzatır, gereksiz.
Test Yapılandırması ve package.json Ayarları
# package.json içindeki jest konfigürasyonu
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:integration": "jest tests/integration --runInBand"
},
"jest": {
"testEnvironment": "node",
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/server.js"
],
"testTimeout": 10000,
"globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js"
}
}
--runInBand flag’i entegrasyon testleri için önemli. Normalde Jest testleri paralel çalıştırır, ama veritabanı bağlantısı gibi paylaşılan kaynaklar varsa sıralı çalıştırmanız gerekir. Paralel çalışma entegrasyon testlerinde yarış koşullarına yol açabilir.
Global setup ve teardown dosyaları:
# tests/setup.js
module.exports = async () => {
// Test veritabanı bağlantısı, migration vs.
process.env.NODE_ENV = 'test';
process.env.DB_NAME = 'testdb';
console.log('Test ortamı hazırlanıyor...');
};
# tests/teardown.js
module.exports = async () => {
// Açık bağlantıları kapat, temp dosyaları sil
console.log('Test ortamı temizleniyor...');
};
Test Kapsamını ve Kalitesini Artırmak
Coverage raporu almak için:
npm run test:coverage
Çıktıda şunlara bakın:
- Statements: Her kod satırının çalışıp çalışmadığı
- Branches: If/else dallarının her iki yolunun da test edilip edilmediği
- Functions: Tüm fonksiyonların çağrılıp çağrılmadığı
- Lines: Satır bazlı kapsam
%80 üzeri branch coverage hedefleyin. %100 mükemmel görünür ama ulaşmak için yazılan anlamsız testler daha büyük sorun. Özellikle hata yollarını ve edge case’leri kapsamaya odaklanın.
Bir örnek vereceğim: Bir projede GET endpoint’inin tüm testleri yeşildi, coverage %90’dı. Ama page query parametresi negatif değer geldiğinde ne olduğunu test eden yoktu. Production’da bir bot negatif sayfa numarası göndererek veritabanı sorgusunu patlatmıştı. O günden beri her sayfalama endpoint’i için mutlaka sınır değer testleri yazıyorum.
# Sınır değer testleri örneği
describe('Sayfalama Testleri', () => {
it('negatif sayfa numarasını reddetmeli', async () => {
await request(app)
.get('/api/users?page=-1&limit=10')
.expect(400);
});
it('limit değeri çok büyük olduğunda sınırlamalı', async () => {
const response = await request(app)
.get('/api/users?page=1&limit=99999')
.expect(200);
// API en fazla 100 kayıt döndürmeli
expect(response.body.data.length).toBeLessThanOrEqual(100);
});
it('sayfa numarası string geldiğinde graceful handle etmeli', async () => {
await request(app)
.get('/api/users?page=abc')
.expect(400);
});
});
CI/CD Entegrasyonu
GitHub Actions ile test sürecini otomatize etmek:
# .github/workflows/test.yml
name: API Testleri
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Node.js kurulumu
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Bağımlılıkları yükle
run: npm ci
- name: Testleri çalıştır
env:
NODE_ENV: test
DB_HOST: localhost
DB_PORT: 5432
DB_NAME: testdb
DB_USER: postgres
DB_PASSWORD: testpass
JWT_SECRET: test-secret-key
run: npm run test:coverage
- name: Coverage raporunu yükle
uses: codecov/codecov-action@v3
npm ci kullanın, npm install değil. CI ortamında npm ci lock dosyasını doğrular ve daha deterministik bir kurulum sağlar.
Sık Yapılan Hatalar
Sahada gördüğüm yaygın hataları listeleyeyim:
- Test izolasyonu eksikliği: Testler birbirinin verilerine bağımlı olmamalı. Her test kendi durumunu oluşturup temizlemeli.
- Sadece happy path testi: Hata durumlarını, boş inputları ve sınır değerleri es geçmek.
- Timeout ayarını unutmak: Yavaş CI ortamlarında async testler default timeout’u aşabilir.
- Gerçek credential kullanmak: Test dosyalarında production veritabanı bilgisi veya gerçek API key bırakmak.
- Supertest instance’ı kapatmamak: Test sonunda açık kalan bağlantılar Jest’in “open handles” uyarısı vermesine yol açar.
Açık handle uyarısını gidermek için:
# Her test dosyasının sonuna eklenebilir
afterAll(done => {
done();
});
Ya da sunucu instance’ını elle kapatın:
let server;
beforeAll(() => {
server = app.listen(0); // 0 = rastgele port
});
afterAll(done => {
server.close(done);
});
Sonuç
Supertest ile test yazmak başlangıçta ekstra iş gibi görünse de orta vadede ciddi zaman kazandırıyor. Özellikle şu senaryolarda farkını hissediyorsunuz: yeni geliştirici ekibe dahil olduğunda, büyük refactor yapıldığında veya dependency upgrade’i gerektiğinde.
Test yazmanın en büyük engeli “nasıl başlayacağım” sorusu. Cevap basit: en kritik endpoint’inizden başlayın. Login veya sipariş oluşturma gibi iş açısından en değerli olanı seçin, oraya odaklanın. Zamanla diğerleri de eklenir.
Bir noktanın altını çizerek bitireyim: Testler dokümantasyon gibi de çalışır. İyi yazılmış bir test dosyası, yeni bir ekip üyesine API’nin nasıl davranması gerektiğini belgelerden çok daha iyi anlatır. Bu perspektiften bakıldığında test yazmak sadece hata yakalamakla kalmaz, bilgi birikimini de korur.
