pytest-cov ile Python Test Coverage Ölçümü

Kod tabanında bir şeylerin kırıldığını ancak testler geçince fark etmek, sysadmin dünyasında “sunucu kapandı ama monitoring alarm vermedi” hissiyle birebir aynıdır. Test yazmak güzel, ama o testlerin kodun hangi kısmını gerçekten çalıştırdığını bilmemek sizi yanlış bir güven duygusuna sürükler. İşte tam bu noktada pytest-cov devreye giriyor.

Bu yazıda pytest-cov’u sıfırdan kurulumdan başlayarak, CI/CD pipeline’larına entegrasyona kadar ele alacağız. Örnekler gerçek dünya senaryolarından geliyor; “hello world” coverage ölçen bir yazı değil bu.

pytest-cov Nedir ve Neden Önemlidir

pytest-cov, Python’un coverage.py kütüphanesini pytest ile entegre eden bir eklentidir. Tek başına coverage.py kullanabilirsiniz, ancak pytest ile çalışıyorsanız pytest-cov bu süreci çok daha akıcı hale getirir.

Coverage ölçümünün size söyledikleri:

  • Hangi kod satırlarının testler tarafından çalıştırıldığı
  • Hangi branch’lerin (if/else dallarının) hiç test edilmediği
  • Hangi modüllerin tamamen görmezden gelindiği

Coverage ölçümünün size söylemedikleri:

  • Testlerinizin kaliteli olup olmadığı
  • Edge case’leri yakaladığınız
  • Kodunuzun doğru çalıştığı

%100 coverage, kodunuzun bug-free olduğu anlamına gelmez. Ama %30 coverage, ciddi kör noktalarınız olduğunun işaretidir. Bu dengeyi aklınızda tutarak ilerleyelim.

Kurulum ve Temel Kullanım

Önce temiz bir ortam oluşturalım:

python -m venv .venv
source .venv/bin/activate  # Windows'ta: .venvScriptsactivate
pip install pytest pytest-cov

Basit bir proje yapısıyla başlayalım:

myproject/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   └── validator.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   └── test_validator.py
└── pyproject.toml

calculator.py içeriği:

# src/calculator.py

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Sifira bolme hatasi")
    return a / b

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    return True

Şimdi bu modül için testlerimizi yazalım ve coverage’ı ölçelim:

# En basit kullanim
pytest tests/ --cov=src

# Daha detayli rapor icin
pytest tests/ --cov=src --cov-report=term-missing

# Satir numaralariyla birlikte hangi satirlarin eksik oldugunu goster
pytest tests/ --cov=src --cov-report=term-missing:skip-covered

--cov-report=term-missing parametresi, terminal çıktısında hangi satırların test edilmediğini doğrudan gösterir. Bu parametreyi kullanmadan sadece yüzde değeri görürsünüz, ama hangi satırların eksik olduğunu göremezsiniz. Günlük geliştirme sırasında bu parametre hayat kurtarır.

Rapor Formatları

pytest-cov birden fazla rapor formatını aynı anda üretebilir. CI ortamlarında HTML raporu depoya artifact olarak eklerken, terminal raporunu da anlık geri bildirim için kullanabilirsiniz:

pytest tests/ 
  --cov=src 
  --cov-report=term-missing 
  --cov-report=html:coverage_html 
  --cov-report=xml:coverage.xml 
  --cov-report=json:coverage.json

Rapor formatlarının kullanım alanları:

  • term: Terminal çıktısı, hızlı kontrol için
  • term-missing: Terminal çıktısı + eksik satır numaraları
  • html: İnsan tarafından okunabilir detaylı rapor, code review süreçleri için
  • xml: SonarQube, Codecov gibi araçların tükettiği format
  • json: Özel araçlar veya script’ler için makine okunabilir format
  • lcov: LCOV formatı, bazı CI araçlarıyla uyumlu

HTML raporu açtığınızda her dosya için satır bazında renklendirilmiş bir görünüm elde edersiniz. Yeşil satırlar test edilen, kırmızı satırlar test edilmeyen kodlardır. Bu raporu ekibinizle paylaşmak, “şu modülün testleri eksik” tartışmalarını çok daha somut hale getirir.

pyproject.toml ile Konfigürasyon

Her seferinde uzun parametreler yazmak yerine, konfigürasyonu pyproject.toml dosyasına taşıyın:

# pyproject.toml

[tool.pytest.ini_options]
addopts = """
    --cov=src
    --cov-report=term-missing
    --cov-report=html:coverage_html
    --cov-fail-under=80
"""
testpaths = ["tests"]

[tool.coverage.run]
source = ["src"]
omit = [
    "*/migrations/*",
    "*/settings/*",
    "src/__init__.py",
    "*/conftest.py",
]
branch = true

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "pass",
]
show_missing = true
precision = 2

Bu konfigürasyonun birkaç kritik noktasına dikkat çekmek istiyorum:

branch = true: Bu ayar branch coverage’ı aktive eder. Sadece satır coverage’ı ölçmek yeterli değildir. Bir if bloğunun her iki dalı da test edilmeli. branch = true olmadan, if b == 0: satırı “test edildi” olarak işaretlenir ama b‘nin 0 olduğu durum hiç test edilmemiş olabilir.

--cov-fail-under=80: Coverage belirli bir eşiğin altına düşerse pytest non-zero exit code döner. Bu sayede CI pipeline’ınız otomatik olarak başarısız olur. Eşiği %80 olarak belirledim; bu değer projenize göre değişmeli, ama %60’ın altı ciddi bir sorun işaretidir.

omit: Migration dosyaları, settings dosyaları gibi test yazmanın pek de anlamlı olmadığı yerleri coverage hesabından çıkarın. Aksi takdirde yanıltıcı düşük rakamlar görürsünüz.

Branch Coverage ile Gerçek Kör Noktaları Bulmak

Branch coverage’ın neden önemli olduğunu somut bir örnekle gösterelim:

# src/validator.py

def validate_age(age):
    if not isinstance(age, int):
        raise TypeError("Yas tamsayi olmalidir")
    if age < 0:
        raise ValueError("Yas negatif olamaz")
    if age > 150:
        raise ValueError("Gecersiz yas degeri")
    return True

def validate_email(email):
    if not email:
        return False
    if "@" not in email:
        return False
    parts = email.split("@")
    if len(parts) != 2:
        return False
    local, domain = parts
    if not local or not domain:
        return False
    if "." not in domain:
        return False
    return True

Test dosyamız:

# tests/test_validator.py

import pytest
from src.validator import validate_age, validate_email


def test_validate_age_normal():
    assert validate_age(25) is True


def test_validate_age_zero():
    assert validate_age(0) is True


def test_validate_email_valid():
    assert validate_email("[email protected]") is True


def test_validate_email_no_at():
    assert validate_email("invalidemail.com") is False

Bu testleri çalıştırıp branch coverage’a bakalım:

pytest tests/test_validator.py --cov=src/validator --cov-report=term-missing -v

Çıktıda göreceksiniz ki validate_age fonksiyonunda age > 150 ve not isinstance(age, int) branch’leri hiç test edilmemiş. validate_email‘de ise boş string ve domain’de nokta olmaması durumları eksik.

Satır coverage’a bakarsanız rakamlar yanıltıcı derecede iyi görünebilir. Branch coverage gerçeği yüzünüze vurur.

# pragma: no cover Direktifi

Bazı kodları bilinçli olarak coverage hesabının dışında tutmak isteyebilirsiniz:

# src/calculator.py

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("Sifira bolme hatasi")
    return a / b

def debug_info():  # pragma: no cover
    """Sadece debug amacli, production'da calismiyor"""
    import platform
    print(f"Python: {platform.python_version()}")
    print(f"Platform: {platform.system()}")

if __name__ == "__main__":  # pragma: no cover
    print(divide(10, 2))

Bu direktifi dikkatli kullanın. Ekibinizde “canım sıkıldı, bunu test etmek istemiyorum, pragma: no cover yazayım” kültürü oluşmasına izin vermeyin. Code review sürecinde bu satırların varlığı sorgulanmalı. Meşru kullanım alanları:

  • if __name__ == "__main__": blokları
  • def __repr__ metodları
  • Platform spesifik kod dalları
  • Abstract metodlar

CI/CD Entegrasyonu: GitHub Actions

Gerçek dünyada coverage ölçümü local’de değil, CI’da anlam kazanır. GitHub Actions için tipik bir konfigürasyon:

# .github/workflows/test.yml

name: Test ve Coverage

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"]

    steps:
      - uses: actions/checkout@v4

      - name: Python ${{ matrix.python-version }} kur
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Bagimliliklari yukle
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

      - name: Testleri calistir ve coverage olc
        run: |
          pytest tests/ 
            --cov=src 
            --cov-report=xml 
            --cov-report=term-missing 
            --cov-fail-under=80

      - name: Coverage raporunu Codecov'a yukle
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml
          fail_ci_if_error: true
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

      - name: HTML raporu artifact olarak kaydet
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-html-py${{ matrix.python-version }}
          path: coverage_html/
          retention-days: 7

Bu workflow’da birkaç önemli nokta var: if: always() ile HTML raporunu testler başarısız olsa bile artifact olarak kaydediyoruz. Testler neden başarısız olduğunu araştırırken coverage raporuna bakmak isteyebilirsiniz.

Büyük Projelerde Coverage Yönetimi

Büyük projelerde tüm modüller için tek bir coverage hedefi belirlemek çoğu zaman pratik değildir. Farklı modüller için farklı hedefler tanımlayabilirsiniz:

# pyproject.toml - Buyuk proje icin genis konfigurasyon

[tool.coverage.run]
source = ["src"]
branch = true
parallel = true
concurrency = ["multiprocessing", "thread"]

[tool.coverage.paths]
source = [
    "src/",
    "*/site-packages/myproject/",
]

[tool.coverage.report]
fail_under = 75
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "@(abc\.)?abstractmethod",
]
omit = [
    "src/migrations/*",
    "src/*/migrations/*",
    "src/settings/*.py",
    "src/manage.py",
]

[tool.coverage.html]
directory = "coverage_html"
title = "MyProject Coverage Raporu"

Paralel testlerde coverage ölçümü yaparken dikkat etmeniz gereken kritik bir nokta var: parallel = true ayarıyla birden fazla process’te çalışan testler ayrı .coverage.* dosyaları oluşturur. Bunları birleştirmek için:

# Paralel testleri calistir
pytest tests/ --cov=src --cov-parallel -n auto

# Coverage dosyalarini birlestir
coverage combine

# Rapor uret
coverage report --show-missing
coverage html

pytest-xdist ile paralel test çalıştırıyorsanız bu adım zorunludur. Birleştirme yapmadan rapor üretirseniz ya hata alırsınız ya da eksik verilerle karşılaşırsınız.

Coverage Trend Takibi

Tek seferlik coverage ölçümü yeterli değildir. Zaman içinde coverage’ın nasıl değiştiğini takip etmek gerekir. Basit bir shell script ile bunu yapabilirsiniz:

#!/bin/bash
# scripts/check_coverage_trend.sh

set -e

THRESHOLD=80
COVERAGE_FILE="coverage_history.txt"
DATE=$(date +%Y-%m-%d)

# Testleri calistir ve coverage yuzdesini al
COVERAGE=$(pytest tests/ --cov=src --cov-report=term -q 2>/dev/null | 
           grep "TOTAL" | 
           awk '{print $NF}' | 
           tr -d '%')

echo "Bugunun coverage degeri: %${COVERAGE}"

# Gecmis degerlerle karsilastir
if [ -f "$COVERAGE_FILE" ]; then
    LAST_COVERAGE=$(tail -1 "$COVERAGE_FILE" | awk '{print $2}')
    DIFF=$(echo "$COVERAGE - $LAST_COVERAGE" | bc)
    
    if (( $(echo "$DIFF < -5" | bc -l) )); then
        echo "UYARI: Coverage son kayittan bu yana %${DIFF} dustu!"
        exit 1
    fi
fi

# Gunluk kayit
echo "$DATE $COVERAGE" >> "$COVERAGE_FILE"

# Minimum esigi kontrol et
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
    echo "HATA: Coverage %${THRESHOLD} esiginin altinda: %${COVERAGE}"
    exit 1
fi

echo "Coverage kontrolu basarili: %${COVERAGE}"

Bu script’i CI pipeline’ınıza entegre ettiğinizde, coverage’ın aniden düşmesi durumunda build’i otomatik olarak kırabilirsiniz.

Sık Yapılan Hatalar

Yanlış kaynak belirtmek: --cov=. veya --cov=src yerine modül adı vermek bazen beklenmedik sonuçlar doğurur. Her zaman kaynak dizinini açıkça belirtin.

Test kodunun coverage’a dahil edilmesi: Eğer --cov=. kullanırsanız test dosyaları da ölçüme dahil olur ve rakamlar yanıltıcı biçimde şişer. omit listesine test dizinini ekleyin ya da --cov=src gibi belirli dizini hedefleyin.

Coverage’ı tek başına kalite ölçütü saymak: %95 coverage’a sahip ama tüm assertion’ları assert True olan bir test suite düşünün. Coverage raporu mükemmel görünür, ama testler hiçbir şeyi doğrulamıyor. Coverage araç, amaç değil.

Paralel testlerde combine yapmayı unutmak: pytest-xdist ile paralel çalışırken coverage combine adımını atlamak eksik ve hatalı raporlara yol açar.

Sonuç

pytest-cov, Python projelerinde kod kalitesini somut metriklerle takip etmenin en pratik yollarından biridir. Ancak birkaç şeyi net tutmak gerekiyor:

Coverage ölçümü bir araçtır, bir hedef değil. “Coverage’ı %80’e çıkaralım” hedefi değil, “%80 coverage’a sahip olmak, kodumuzun kritik noktalarının test edildiğine dair makul bir güvence sağlar” bakış açısı doğru olan.

Branch coverage’ı mutlaka aktive edin. Sadece satır coverage’ı ölçmek, testlerin if/else dallarını atladığı durumları gizler. branch = true ayarı olmadan ölçüm yaptığınızda gerçek kör noktaları göremezsiniz.

CI’a entegre etmeden önce local’de deneyin. Coverage konfigürasyonunu doğru kurmak bazen birkaç deneme gerektirir. pyproject.toml‘u doğru yapılandırdıktan sonra CI’a alın.

Coverage eşiğini proje başlangıcında düşük tutup zamanla artırın. Legacy bir projeye %80 zorunluluğu getirirseniz tüm ekibin çalışması durur. Önce mevcut durumu ölçün, gerçekçi bir hedef koyun, zamanla yukarı çekin.

Son olarak: coverage raporunu ekibinizle paylaşın. HTML raporu görsel olarak çok açıklayıcıdır. Code review sırasında “bu PR’da test edilmeyen satırlar var, buraya bir göz atalım” diyebilmek, teorik tartışmalar yerine somut adımlar atmanızı sağlar.

Bir yanıt yazın

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