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.

Bir yanıt yazın

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