CI/CD Pipeline’a Otomatik Test Entegrasyonu
Sabah 03:00’te production’a deploy açtın, testler geçti, her şey yeşil. Ama 10 dakika sonra müşterilerden şikayet yağmağa başladı. Tanıdık geldi mi? Ben bu senaryoyu birkaç kez yaşadım ve her seferinde aynı soruyu sordum kendime: “Neden bu testi yazmadım?”
CI/CD pipeline’larına otomatik test entegrasyonu, tam olarak bu tür gece yarısı sürprizlerini önlemek için var. Ama teoride “test yaz, pipeline’a ekle” kadar basit görünen bu konu, pratikte çok daha derin bir düşünce gerektiriyor. Hangi testleri yazacaksın? Ne zaman koşturacaksın? Başarısız bir test pipeline’ı durdurmalı mı, sadece uyarmalı mı? Bu soruları yanıtlamadan pipeline’a test eklemek, “test varı” sanrısı yaratmaktan öteye geçmiyor.
Test Piramidi ve Pipeline Gerçeği
Önce teorik zemini oturalım. Test piramidi denen kavram, altında birim testleri, ortasında entegrasyon testleri, tepede E2E (uçtan uca) testleri olacak şekilde bir hiyerarşi önerir. Bu piramidin CI/CD’ye yansıması da aynı mantıkla işler: hızlı ve ucuz testler önce, yavaş ve pahalı testler sonra.
Pratikte şunu görüyorum: Ekiplerin büyük çoğunluğu bu piramidi tersine çeviriyor. Çünkü birim testi yazmak disiplin istiyor, entegrasyon testi kurmak altyapı istiyor, ama Selenium ile ekrana tıklayan E2E testi yazmak… bu herkesin anlayabileceği bir şey. Sonuç? 200 E2E test, 5 birim testi ve pipeline her push’ta 45 dakika koşuyor.
İdeal bir CI/CD pipeline test akışı şöyle düşünülmeli:
- Stage 1 (Hızlı Kontroller, 2-5 dk): Birim testleri, lint, statik analiz
- Stage 2 (Entegrasyon, 5-15 dk): Servis entegrasyon testleri, API testleri
- Stage 3 (Yavaş Testler, 15-45 dk): E2E testler, performans smoke testleri
- Stage 4 (Production Öncesi): Güvenlik taramaları, yük testleri
Her stage başarısız olursa sonraki stage’e geçilmez. Bu basit kural, ekiplerin zamanını boşa harcamadan hata yakalamasını sağlar.
GitLab CI ile Test Entegrasyonu
GitLab CI, özellikle Türkiye’deki kurumsal ortamlarda oldukça yaygın. Aşağıdaki örnek gerçek bir Node.js projesi için hazırladığım bir yapıdan uyarlandı.
# .gitlab-ci.yml
stages:
- lint
- unit-test
- integration-test
- e2e-test
- security
variables:
NODE_ENV: test
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass123
lint-and-static:
stage: lint
image: node:18-alpine
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
script:
- npm ci
- npm run lint
- npm run type-check
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
unit-tests:
stage: unit-test
image: node:18-alpine
script:
- npm ci
- npm run test:unit -- --coverage --coverageReporters=cobertura
coverage: '/Statementss*:s*(d+.?d*)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
expire_in: 1 week
integration-tests:
stage: integration-test
image: node:18-alpine
services:
- postgres:15-alpine
- redis:7-alpine
script:
- npm ci
- npm run db:migrate:test
- npm run test:integration
artifacts:
when: always
reports:
junit: test-results/integration-junit.xml
Dikkat etmeni istediğim birkaç nokta var. coverage alanındaki regex pattern, GitLab’ın coverage badge’ini doğru göstermesi için kritik. Yanlış yazılmış pattern yüzünden “coverage: 0%” gösteren pipeline’larla o kadar çok karşılaştım ki. Ayrıca artifacts‘ı when: always yapman, başarısız testlerde bile raporlara ulaşabilmeni sağlıyor. Başarısız test raporuna bakamıyorsan ne anlamı var?
Jest ile Birim Test Konfigürasyonu
Node.js dünyasında Jest defacto standart haline geldi. Ama “npm test” deyip geçmek yerine pipeline için özelleştirilmiş bir konfigürasyon kurmak önemli.
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
transform: {
'^.+\.tsx?$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/migrations/**',
'!src/seeds/**'
],
coverageThresholds: {
global: {
branches: 70,
functions: 75,
lines: 80,
statements: 80
}
},
reporters: [
'default',
['jest-junit', {
outputDirectory: './test-results',
outputName: 'unit-junit.xml',
classNameTemplate: '{classname}',
titleTemplate: '{title}'
}]
],
testTimeout: 10000,
maxWorkers: '50%'
};
coverageThresholds kısmı çoğu zaman tartışma konusu oluyor. “%80 coverage şart” diyorsun, ekip da kritik iş mantığı yerine setter/getter testleri yazarak sayıyı dolduruyor. Bu yüzden ben thresholds’u proje başında düşük tutup zamanla artırmayı tercih ediyorum. Projeye sonradan giren biri için 0’dan 80’e çıkarmak gerçekçi değil.
Python Projesi İçin pytest ve GitHub Actions
Python backend’leri için pytest + GitHub Actions kombinasyonu şöyle görünüyor:
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Python kurulum
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Bağımlılıkları yükle
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Testleri koştur
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
SECRET_KEY: test-secret-key-for-ci
run: |
pytest tests/
--cov=app
--cov-report=xml
--cov-report=term-missing
--junitxml=test-results/junit.xml
-v
--tb=short
- name: Coverage raporu yükle
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
fail_ci_if_error: true
Matrix strategy burada güzel bir özellik. Tek push’ta 3 farklı Python versiyonunda test koşturmak, kütüphane uyumluluk sorunlarını önceden yakalamanı sağlıyor. Bir keresinde Python 3.9’da çalışan bir kod 3.11’de datetime.timezone değişikliği yüzünden patladı. Matrix olmasaydı bunu production’da öğrenirdik.
Docker ile İzole Test Ortamı
Testlerin “bende çalışıyordu” sorununu çözmenin en güvenilir yolu, tüm test ortamını Docker içine almak:
# Dockerfile.test
FROM python:3.11-slim as test-base
WORKDIR /app
RUN apt-get update && apt-get install -y
gcc
libpq-dev
&& rm -rf /var/lib/apt/lists/*
COPY requirements*.txt ./
RUN pip install --no-cache-dir -r requirements.txt
&& pip install --no-cache-dir -r requirements-test.txt
COPY . .
FROM test-base as unit-tests
CMD ["pytest", "tests/unit/", "-v", "--tb=short", "--junitxml=/reports/unit.xml"]
FROM test-base as integration-tests
CMD ["pytest", "tests/integration/", "-v", "--tb=long", "--junitxml=/reports/integration.xml"]
# docker-compose.test.yml
version: '3.8'
services:
test-db:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
interval: 5s
timeout: 5s
retries: 10
unit-tests:
build:
context: .
dockerfile: Dockerfile.test
target: unit-tests
volumes:
- ./reports:/reports
integration-tests:
build:
context: .
dockerfile: Dockerfile.test
target: integration-tests
environment:
DATABASE_URL: postgresql://testuser:testpass@test-db:5432/testdb
volumes:
- ./reports:/reports
depends_on:
test-db:
condition: service_healthy
Bu yapıyı pipeline’da kullanmak şöyle:
#!/bin/bash
# run-tests.sh
set -e
echo "Unit testler başlıyor..."
docker compose -f docker-compose.test.yml run --rm unit-tests
echo "Unit testler tamamlandı."
echo "Integration testler başlıyor..."
docker compose -f docker-compose.test.yml run --rm integration-tests
echo "Integration testler tamamlandı."
# Temizlik
docker compose -f docker-compose.test.yml down -v --remove-orphans
Test Raporlama ve Görünürlük
Testlerin geçip geçmediğini bilmek yetmez. Neyin ne kadar sürdüğünü, hangi testlerin flaky olduğunu, coverage trendini takip etmek gerekir. Bu bilgileri pipeline’dan çekip görünür kılmak ciddi bir değer katıyor.
Allure Report bu konuda en kapsamlı çözümlerden biri:
# conftest.py - pytest için Allure konfigürasyonu
import pytest
import allure
@pytest.fixture(autouse=True)
def test_metadata(request):
"""Her teste otomatik metadata ekle"""
allure.dynamic.label("suite", request.node.parent.name)
allure.dynamic.label("test_id", request.node.name)
def pytest_runtest_makereport(item, call):
if call.when == "call" and call.excinfo is not None:
# Başarısız testte otomatik ekran görüntüsü veya log ekle
if hasattr(item, '_driver'):
allure.attach(
item._driver.get_screenshot_as_png(),
name="failure_screenshot",
attachment_type=allure.attachment_type.PNG
)
Allure raporlarını GitLab Pages veya GitHub Pages’e otomatik deploy etmek de mümkün. Ekip liderleri kod açmadan “bu sprint testlerin durumu nedir?” sorusunu bir URL ile yanıtlayabilir hale geliyor.
Flaky Test Yönetimi
En çok görmezden gelinen konu bu. Flaky test, bazen geçen bazen geçmeyen testtir. Çoğu ekip bu testleri “eh, tekrar çalıştırırız” diyerek geçiştiriyor. Ama bu yaklaşım pipeline’a olan güveni eritir. Bir süre sonra kırmızı pipeline’a kimse bakmaz çünkü “zaten flaky test vardır” varsayımı yerleşir.
#!/bin/bash
# flaky-detect.sh - Aynı testi N kez koşturup flakiness oranını ölç
TEST_FILE=$1
RUN_COUNT=${2:-10}
PASS=0
FAIL=0
for i in $(seq 1 $RUN_COUNT); do
if pytest "$TEST_FILE" -q --tb=no > /dev/null 2>&1; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
fi
done
echo "Test: $TEST_FILE"
echo "Toplam çalışma: $RUN_COUNT"
echo "Başarılı: $PASS | Başarısız: $FAIL"
FLAKY_RATE=$(echo "scale=1; $FAIL * 100 / $RUN_COUNT" | bc)
echo "Flakiness oranı: %$FLAKY_RATE"
if [ "$FAIL" -gt 0 ] && [ "$FAIL" -lt "$RUN_COUNT" ]; then
echo "UYARI: Bu test flaky!"
exit 1
fi
pytest-rerunfailures eklentisi ile flaky testlere geçici bir nefes hakkı tanıyabilirsin:
# pytest.ini
[pytest]
addopts = --reruns 2 --reruns-delay 1
reruns_except = AssertionError
Ama bu kalıcı çözüm değil. Flaky testleri işaretleyip düzeltmeyi bir teknik borç olarak backlog’a alman gerekiyor.
Paralel Test Koşturma
CI süresini kısaltmanın en etkili yolu paralel test koşturmak. pytest-xdist bunun için:
# 4 paralel worker ile koştur
pytest tests/ -n 4 --dist=worksteal
# Otomatik CPU sayısı kadar worker
pytest tests/ -n auto
# Belirli testleri gruplandırarak paralelize et
pytest tests/ -n 4 --dist=loadgroup
--group="integration"
--group-first="unit"
GitLab’da paralel jobs için:
integration-tests:
stage: integration-test
parallel: 4
script:
- npm ci
- npx jest --testPathPattern="integration"
--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
--forceExit
artifacts:
reports:
junit: test-results/junit-$CI_NODE_INDEX.xml
Bu yaklaşımla 20 dakikalık test suites’ini 6-7 dakikaya indirebildiğimizi gördüm. Paralelize etmek için testlerin birbirinden bağımsız olması şart, aksi halde race condition’larla boğuşursun.
Güvenlik Testlerini Pipeline’a Dahil Etmek
Test entegrasyonu sadece fonksiyonel testlerle sınırlı kalmamalı. SAST (Static Application Security Testing) araçlarını da pipeline’a eklemek, güvenlik açıklarını erken yakalamayı sağlar:
# GitHub Actions - Güvenlik taraması
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bağımlılık güvenlik taraması
run: |
pip install safety bandit
safety check -r requirements.txt --json > safety-report.json || true
bandit -r app/ -f json -o bandit-report.json || true
- name: Rapor yükle
uses: actions/upload-artifact@v3
with:
name: security-reports
path: |
safety-report.json
bandit-report.json
- name: Kritik açık kontrolü
run: |
CRITICAL=$(cat safety-report.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
critical = [v for v in data.get('vulnerabilities', [])
if v.get('severity') == 'critical']
print(len(critical))
")
if [ "$CRITICAL" -gt 0 ]; then
echo "KRİTİK güvenlik açığı bulundu! Pipeline durduruluyor."
exit 1
fi
Kritik açıkta pipeline dursun, orta seviyede sadece uyarsın yaklaşımı pratikte çok işe yarıyor. Her güvenlik bulgusu için pipeline’ı durdurmak ekibi uyarı körlüğüne sürükler.
Gerçek Dünya: Migration Testi Senaryosu
Veritabanı migration’larını test etmek genellikle görmezden gelinir ama production’daki en sık yaşanan felaketlerden biri yanlış migration. Şöyle bir test stratejisi işe yarıyor:
# tests/test_migrations.py
import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy import create_engine, text
@pytest.fixture(scope="session")
def migration_engine(test_database_url):
engine = create_engine(test_database_url)
yield engine
engine.dispose()
def test_migrations_up_down(migration_engine, test_database_url):
"""Tüm migration'ların up/down çiftlerini test et"""
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", test_database_url)
# Tüm migration'ları uygula
command.upgrade(alembic_cfg, "head")
# Revision listesini al
with migration_engine.connect() as conn:
result = conn.execute(
text("SELECT version_num FROM alembic_version")
)
current = result.scalar()
assert current is not None, "Migration uygulanamadı"
# Tamamen geri al
command.downgrade(alembic_cfg, "base")
# Tekrar uygula (idempotency kontrolü)
command.upgrade(alembic_cfg, "head")
def test_no_data_loss_on_migration(migration_engine, test_database_url):
"""Migration öncesi ve sonrası veri bütünlüğünü doğrula"""
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", test_database_url)
# Bir önceki versiyona git
command.upgrade(alembic_cfg, "-1")
# Test verisi ekle
with migration_engine.connect() as conn:
conn.execute(text("INSERT INTO users (email) VALUES ('[email protected]')"))
conn.commit()
# Migration'ı uygula
command.upgrade(alembic_cfg, "head")
# Verinin hala orada olduğunu doğrula
with migration_engine.connect() as conn:
count = conn.execute(
text("SELECT COUNT(*) FROM users WHERE email = '[email protected]'")
).scalar()
assert count == 1, "Migration sırasında veri kaybı yaşandı!"
Bu testi ekledikten sonra bir ekipte “sütun ismi değiştirme” migration’ının veri kaybına yol açacağını deploy öncesi fark ettik. İyi ki pipeline durdurdu.
Sonuç
CI/CD pipeline’ına test entegrasyonu, “test yaz, pipeline’a bağla” kadar basit ama asla bu kadar kolay değil. Doğru test stratejisi seçmek, test ortamını izole etmek, raporlamayı görünür kılmak, flaky testlerle savaşmak ve paralelize etmek bunların hepsi ayrı birer mühendislik kararı.
Şunu söyleyeyim: Mükemmel bir test suite’i bugün kurmaya çalışmak yerine, mevcut pipeline’ına küçük ama anlamlı eklemeler yapmak çok daha sürdürülebilir. Birim test coverage’ı sıfırsa, önce kritik iş mantığı için birkaç test yaz ve CI’ı entegre et. Oradan büyü. Flaky testler varsa önce onları düzelt, sonra yeni test yaz. Coverage thresholds’u gerçekçi tut, zamanla artır.
Gece 03:00 deploy krizi yaşamamak için sabah 10:00’da yazılan bir test yeterli olabiliyor. Pipeline’ın kırmızı yandığında kızma, aslında seni kurtarıyor.
