Uji Unit dalam Python: Panduan Lengkap dengan Contoh

Pengujian Python dengan pytest, TDD, mocking, dan coverage

Konten Halaman

Pengujian unit memastikan kode Python Anda berjalan dengan benar dan terus berfungsi seiring berkembangnya proyek Anda. Panduan komprehensif ini mencakup segala sesuatu yang perlu Anda ketahui tentang pengujian unit di Python, mulai dari konsep dasar hingga teknik lanjutan.

Python Unit Testing

Mengapa Pengujian Unit Penting

Pengujian unit memberikan banyak manfaat bagi pengembang Python:

  • Deteksi Bug Dini: Menangkap bug sebelum mencapai produksi
  • Kualitas Kode: Memaksa Anda menulis kode modular dan dapat diuji
  • Keyakinan Saat Refaktorisasi: Lakukan perubahan dengan aman karena pengujian akan menangkap regresi
  • Dokumentasi: Pengujian berfungsi sebagai dokumentasi eksekusi dari cara kode seharusnya bekerja
  • Pengembangan Lebih Cepat: Pengujian otomatis lebih cepat daripada pengujian manual
  • Desain yang Lebih Baik: Menulis kode yang dapat diuji mengarah ke arsitektur yang lebih baik

Memahami Dasar-Dasar Pengujian Unit

Apa Itu Pengujian Unit?

Pengujian unit memverifikasi bagian terkecil dari aplikasi yang dapat diuji (biasanya fungsi atau metode) secara terisolasi. Ini harus:

  • Cepat: Berjalan dalam milidetik
  • Terisolasi: Tidak bergantung pada pengujian lain atau sistem eksternal
  • Dapat Diulang: Menghasilkan hasil yang sama setiap kali
  • Mengvalidasi Diri Sendiri: Lulus atau gagal secara jelas tanpa inspeksi manual
  • Tepat Waktu: Ditulis sebelum atau bersamaan dengan kode

Piramida Pengujian

Suite pengujian yang sehat mengikuti piramida pengujian:

           /\
          /  \     Pengujian E2E (Sedikit)
         /____\
        /      \   Pengujian Integrasi (Beberapa)
       /_____ ___\
      /          \ Pengujian Unit (Banyak)
     /_____ __ ___\

Pengujian unit membentuk fondasi - mereka banyak, cepat, dan memberikan umpan balik cepat.

Perbandingan Kerangka Kerja Pengujian Python

unittest: Kerangka Kerja Bawaan

Perpustakaan standar Python mencakup unittest, yang terinspirasi oleh JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Dijalankan sebelum setiap pengujian"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Dijalankan setelah setiap pengujian"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Kelebihan:

  • Bawaan, tidak perlu instalasi
  • Cocok untuk pengembang yang sudah familiar dengan kerangka kerja xUnit
  • Ramah perusahaan, sudah mapan

Kekurangan:

  • Sintaks yang rumit dengan boilerplate
  • Memerlukan kelas untuk organisasi pengujian
  • Manajemen fixture yang kurang fleksibel

pytest: Pilihan Modern

pytest adalah kerangka pengujian pihak ketiga yang paling populer:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Kelebihan:

  • Sintaks sederhana, Pythonic
  • Sistem fixture yang kuat
  • Ekosistem plugin yang hebat
  • Pelaporan kesalahan yang lebih baik
  • Pengujian parametrized bawaan

Kekurangan:

  • Memerlukan instalasi
  • Kurang familiar bagi pengembang dari bahasa lain

Instalasi:

pip install pytest pytest-cov pytest-mock

Mari kita bangun contoh sederhana dari awal menggunakan Pengembangan Berbasis Pengujian (TDD). Jika Anda baru dengan Python atau membutuhkan referensi cepat untuk sintaks dan fitur bahasa, lihat Python Cheatsheet kami untuk tinjauan komprehensif tentang dasar-dasar Python.

Contoh: Fungsi Utilitas String

Langkah 1: Tulis Pengujian Terlebih Dahulu (Merah)

Buat test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Langkah 2: Tulis Kode Minimal untuk Melalui Pengujian (Hijau)

Buat string_utils.py:

def reverse_string(s: str) -> str:
    """Balikkan string"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Periksa apakah string adalah palindrome (mengabaikan huruf besar kecil dan spasi)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Hitung vokal dalam string"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Langkah 3: Jalankan Pengujian

pytest test_string_utils.py -v

Keluaran:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Teknik Pengujian Lanjutan

Menggunakan Fixture untuk Pengaturan Pengujian

Fixture menyediakan pengaturan dan penyelesaian pengujian yang dapat digunakan kembali:

import pytest
from database import Database

@pytest.fixture
def db():
    """Buat database pengujian"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Berikan ke pengujian
    database.close()  # Bersihkan setelah pengujian

@pytest.fixture
def sample_users(db):
    """Tambahkan pengguna contoh ke database"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Ruang Lingkup Fixture

Kontrol masa hidup fixture dengan ruang lingkup:

@pytest.fixture(scope="function")  # Default: dijalankan untuk setiap pengujian
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Sekali per kelas pengujian
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Sekali per modul
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Sekali per sesi pengujian
def session_fixture():
    return create_expensive_resource()

Menyamarakan Ketergantungan Eksternal

Gunakan penyamaran untuk memisahkan kode dari ketergantungan eksternal:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Pengujian dengan penyamaran
def test_get_temperature():
    service = WeatherService()
    
    # Menyamar fungsi requests.get
    with patch('requests.get') as mock_get:
        # Konfigurasi respons penyamaran
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # Pengujian
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # Verifikasi pemanggilan
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Menggunakan Plugin pytest-mock

pytest-mock menyediakan sintaks yang lebih bersih:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Menyamar menggunakan pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

Pengujian Parametrized

Uji berbagai skenario secara efisien:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Pengujian Eksepsi dan Penanganan Kesalahan

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Tidak dapat membagi dengan nol")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Tidak dapat membagi dengan nol"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "nol" in str(exc_info.value).lower()

# Pengujian bahwa tidak ada eksepsi yang diangkat
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Pengujian Kode Asinkron

Pengujian kode asinkron sangat penting untuk aplikasi Python modern, terutama ketika bekerja dengan API, database, atau layanan AI. Berikut cara menguji fungsi asinkron:

import pytest
import asyncio

async def fetch_data(url):
    """Fungsi asinkron untuk mengambil data"""
    await asyncio.sleep(0.1)  # Simulasikan panggilan API
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Menyamar fungsi asinkron
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Untuk contoh praktis pengujian kode asinkron dengan layanan AI, lihat panduan kami tentang mengintegrasikan Ollama dengan Python, yang mencakup strategi pengujian untuk interaksi LLM.

Cakupan Kode

Ukur seberapa banyak kode Anda yang diuji:

Menggunakan pytest-cov

# Jalankan pengujian dengan cakupan
pytest --cov=myproject tests/

# Hasilkan laporan HTML
pytest --cov=myproject --cov-report=html tests/

# Tunjukkan baris yang hilang
pytest --cov=myproject --cov-report=term-missing tests/

Konfigurasi Cakupan

Buat .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Praktik Terbaik Cakupan

  1. Targetkan 80%+ cakupan untuk jalur kode kritis
  2. Jangan terlalu memperhatikan 100% - fokus pada pengujian yang bermakna
  3. Uji kasus batas bukan hanya jalur yang sukses
  4. Keluarkan boilerplate dari laporan cakupan
  5. Gunakan cakupan sebagai panduan bukan tujuan

Organisasi Pengujian dan Struktur Proyek

Struktur yang Direkomendasikan

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Fixture bersama
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py untuk Fixture Bersama

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Database pengujian seluruh sesi"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Database bersih untuk setiap pengujian"""
    test_db.clear_all_tables()
    return test_db

Konfigurasi pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: menandai pengujian sebagai lambat
    integration: menandai pengujian sebagai pengujian integrasi
    unit: menandai pengujian sebagai pengujian unit

Praktik Terbaik untuk Pengujian Unit

1. Ikuti Pola AAA

Arrange-Act-Assert membuat pengujian jelas:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Satu Pernyataan Per Pengujian (Petunjuk, Bukan Aturan)

# Baik: Pengujian fokus
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# Juga diterima: Pernyataan terkait
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Gunakan Nama Pengujian yang Deskriptif

# Buruk
def test_user():
    pass

# Baik
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Uji Kasus Batas dan Batas

def test_age_validation():
    # Kasus valid
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Kasus batas
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Kasus batas
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Pertahankan Pengujian yang Mandiri

# Buruk: Pengujian bergantung pada urutan
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Gagal jika dijalankan sendiri
    global counter
    counter += 1
    assert counter == 2

# Baik: Pengujian mandiri
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. Jangan Uji Detail Implementasi

# Buruk: Mengujikan implementasi
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Baik: Mengujikan perilaku
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Uji Kasus Penggunaan Dunia Nyata

Ketika menguji perpustakaan yang memproses atau mengubah data, fokuslah pada skenario dunia nyata. Misalnya, jika Anda bekerja dengan pengambilan data web atau konversi konten, lihat panduan kami tentang mengubah HTML ke Markdown dengan Python, yang mencakup strategi pengujian dan perbandingan benchmark untuk berbagai perpustakaan konversi.

Integrasi dengan Continuous Integration

Contoh GitHub Actions

# .github/workflows/tests.yml
name: Pengujian

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Run tests
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Pengujian Fungsi Serverless

Ketika menguji fungsi AWS Lambda atau aplikasi serverless, pertimbangkan strategi pengujian integrasi bersama dengan pengujian unit. Panduan kami tentang membangun AWS Lambda ganda dengan Python dan Terraform mencakup pendekatan pengujian untuk aplikasi Python serverless, termasuk cara menguji penangan Lambda, konsumen SQS, dan integrasi API Gateway.

Daftar Pemeriksaan Praktik Terbaik Pengujian

  • Tulis pengujian sebelum atau bersamaan dengan kode (TDD)
  • Pertahankan pengujian yang cepat (< 1 detik per pengujian)
  • Buat pengujian mandiri dan terisolasi
  • Gunakan nama pengujian yang deskriptif
  • Ikuti pola AAA (Arrange-Act-Assert)
  • Uji kasus batas dan kondisi kesalahan
  • Sisipkan ketergantungan eksternal
  • Targetkan cakupan kode 80%+
  • Jalankan pengujian dalam pipeline CI/CD
  • Tinjau dan refactor pengujian secara berkala
  • Dokumentasikan skenario pengujian kompleks
  • Gunakan fixture untuk pengaturan umum
  • Parametriskan pengujian yang mirip
  • Pertahankan pengujian sederhana dan mudah dibaca

Pola Pengujian Umum

Pengujian Kelas

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Pengujian dengan File Sementara

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Buat file sementara"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Pengujian Pembuatan File

Ketika menguji kode yang menghasilkan file (seperti PDF, gambar, atau dokumen), gunakan direktori sementara dan verifikasi properti file:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Sediakan direktori output sementara"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Untuk contoh komprehensif pengujian pembuatan PDF, lihat panduan kami tentang membuat PDF dalam Python, yang mencakup strategi pengujian untuk berbagai perpustakaan PDF.

Pengujian dengan Monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Tautan dan Sumber Daya Berguna

Sumber Daya Terkait

Dasar-Dasar Python dan Praktik Terbaik

  • Python Cheatsheet - Referensi komprehensif untuk sintaks Python, struktur data, dan pola umum

Pengujian Kasus Penggunaan Python Spesifik

Pengujian Serverless dan Cloud


Pengujian unit adalah keterampilan penting bagi pengembang Python. Baik Anda memilih unittest atau pytest, kuncinya adalah menulis pengujian secara konsisten, menjaga agar mereka dapat dipelihara, dan mengintegrasikannya ke dalam alur kerja pengembangan Anda. Mulailah dengan pengujian sederhana, secara bertahap adopsi teknik lanjutan seperti penyamaran dan fixture, dan gunakan alat cakupan untuk mengidentifikasi kode yang belum diuji.