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.
