1. Pengenalan Testing
Testing adalah proses memverifikasi bahwa kode berfungsi sesuai harapan. Dalam pengembangan perangkat lunak, testing membantu menemukan bug lebih awal, memastikan kode tidak rusak setelah perubahan (regression), dan memberikan dokumentasi tentang perilaku kode.
Jenis-jenis Testing
| Jenis | Deskripsi | Contoh |
|---|---|---|
| Unit Test | Menguji fungsi/komponen secara individual | Test fungsi hitung_total() |
| Integration Test | Menguji interaksi antar komponen | Test API + Database |
| Functional Test | Menguji fitur dari perspektif pengguna | Test login flow |
| End-to-End Test | Menguji seluruh alur aplikasi | Test checkout dari keranjang |
Mengapa pytest?
| Keunggulan | Penjelasan |
|---|---|
| Sintaks Sederhana | Cukup gunakan assert, tidak perlu method khusus |
| Auto-discovery | Otomatis menemukan file test yang dimulai dengan test_ |
| Fixtures | Sistem setup/teardown yang powerful dan reusable |
| Parametrize | Jalankan test yang sama dengan data berbeda |
| Rich Plugin Ekosistem | Ribuan plugin: coverage, HTML report, parallel, dll |
| Backward Compatible | Bisa menjalankan test dari unittest dan nose |
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ TESTING PYRAMID โ โ โ โ /\ โ โ / \ โ โ / E2E \ โ Sedikit, lambat, โ โ /________\ mahal โ โ / \ โ โ / Integration\ โ Sedang, menengah โ โ /______________\ โ โ / \ โ โ / Unit Tests \ โ Banyak, cepat, โ โ /____________________\ murah โ โ โ โ Unit: 70% | Integration: 20% | E2E: 10% โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
2. Memulai dengan pytest
Instalasi
# Instal pytest pip install pytest # Instal dengan plugin populer pip install pytest pytest-cov pytest-html pytest-mock # Verifikasi pytest --version # pytest 8.2.2
Test Pertama
# kalkulator.py โ Kode yang akan diuji
def tambah(a, b):
return a + b
def kurang(a, b):
return a - b
def kali(a, b):
return a * b
def bagi(a, b):
if b == 0:
raise ValueError("Tidak bisa dibagi nol")
return a / b
def rata_rata(angka_list):
if not angka_list:
raise ValueError("List tidak boleh kosong")
return sum(angka_list) / len(angka_list)
# test_kalkulator.py โ File test
import pytest
from kalkulator import tambah, kurang, kali, bagi, rata_rata
# Test function โ diawali dengan "test_"
def test_tambah():
assert tambah(2, 3) == 5
assert tambah(-1, 1) == 0
assert tambah(0, 0) == 0
assert tambah(1.5, 2.5) == 4.0
def test_kurang():
assert kurang(5, 3) == 2
assert kurang(0, 5) == -5
def test_kali():
assert kali(3, 4) == 12
assert kali(-2, 3) == -6
assert kali(0, 100) == 0
def test_bagi():
assert bagi(10, 2) == 5.0
assert bagi(7, 2) == 3.5
def test_bagi_dengan_nol():
# Test bahwa error di-raise
with pytest.raises(ValueError, match="Tidak bisa dibagi nol"):
bagi(10, 0)
def test_rata_rata():
assert rata_rata([1, 2, 3, 4, 5]) == 3.0
assert rata_rata([10]) == 10.0
def test_rata_rata_kosong():
with pytest.raises(ValueError, match="List tidak boleh kosong"):
rata_rata([])
# Menjalankan test:
# pytest โ Jalankan semua test
# pytest test_kalkulator.py โ Jalankan file tertentu
# pytest -v โ Verbose output
# pytest -x โ Stop di test pertama yang gagal
# pytest -k "tambah" โ Jalankan test yang mengandung "tambah"
Hasil Test
$ pytest test_kalkulator.py -v ============================= test session starts ============================= collected 7 items test_kalkulator.py::test_tambah PASSED [ 14%] test_kalkulator.py::test_kurang PASSED [ 28%] test_kalkulator.py::test_kali PASSED [ 42%] test_kalkulator.py::test_bagi PASSED [ 57%] test_kalkulator.py::test_bagi_dengan_nol PASSED [ 71%] test_kalkulator.py::test_rata_rata PASSED [ 85%] test_kalkulator.py::test_rata_rata_kosong PASSED [100%] ============================== 7 passed in 0.05s ==============================
Konvensi penamaan pytest: file test dimulai dengan test_ atau diakhiri dengan _test.py. Fungsi test dimulai dengan test_. Class test dimulai dengan Test. pytest akan otomatis menemukan dan menjalankan semua test yang sesuai konvensi.
3. Fixtures: Setup & Teardown
Fixtures adalah fungsi yang menyediakan data atau objek yang dibutuhkan oleh test. Fixtures digunakan untuk setup (persiapan) dan teardown (pembersihan) sebelum dan sesudah test berjalan. Ini sangat berguna untuk menghindari kode duplikat.
# conftest.py โ Fixtures yang bisa dipakai di semua test
import pytest
import tempfile
import os
import json
# Fixture sederhana
@pytest.fixture
def sample_data():
"""Menyediakan data sample untuk testing."""
return {
'nama': 'Budi',
'umur': 25,
'kota': 'Jakarta'
}
# Fixture dengan yield (teardown)
@pytest.fixture
def temp_file():
"""Membuat file temporary, dihapus setelah test."""
# Setup โ buat file
filepath = tempfile.mktemp(suffix='.txt')
with open(filepath, 'w') as f:
f.write("Test data\n")
yield filepath # โ Test berjalan di sini
# Teardown โ hapus file
if os.path.exists(filepath):
os.remove(filepath)
# Fixture dengan scope
@pytest.fixture(scope='module')
def database_connection():
"""Koneksi database โ hanya dibuat sekali per module."""
print("\n๐ก Membuka koneksi database...")
connection = {'host': 'localhost', 'port': 5432, 'connected': True}
yield connection
print("\n๐ Menutup koneksi database...")
connection['connected'] = False
# Fixture yang memanggil fixture lain
@pytest.fixture
def user_with_data(sample_data):
"""User yang sudah punya data."""
return {
'user': sample_data,
'role': 'admin',
'is_active': True
}
# Fixture autouse โ otomatis dijalankan untuk semua test
@pytest.fixture(autouse=True)
def setup_test_environment():
"""Setup otomatis untuk setiap test."""
print("\nโ๏ธ Setup test environment...")
yield
print("\n๐งน Cleanup test environment...")
Menggunakan Fixtures
# test_fixtures.py โ Menggunakan fixtures
import pytest
def test_sample_data(sample_data):
"""Menerima fixture sebagai parameter."""
assert sample_data['nama'] == 'Budi'
assert sample_data['umur'] == 25
assert 'kota' in sample_data
def test_temp_file_exists(temp_file):
"""File temporary sudah dibuat sebelum test."""
import os
assert os.path.exists(temp_file)
with open(temp_file, 'r') as f:
content = f.read()
assert content == "Test data\n"
def test_database_connection(database_connection):
"""Koneksi database tersedia."""
assert database_connection['connected'] is True
assert database_connection['host'] == 'localhost'
def test_user_role(user_with_data):
"""User dengan data sudah tersedia."""
assert user_with_data['user']['nama'] == 'Budi'
assert user_with_data['role'] == 'admin'
assert user_with_data['is_active'] is True
# Fixture scope options:
# scope='function' โ default, baru setiap test
# scope='class' โ baru setiap class test
# scope='module' โ baru setiap file test
# scope='package' โ baru setiap package
# scope='session' โ baru sekali untuk semua test
4. Parametrize: Test Cases Multiparameter
@pytest.mark.parametrize memungkinkan Anda menjalankan test yang sama dengan berbagai kombinasi input dan expected output. Ini mengurangi duplikasi kode test secara signifikan.
import pytest
from kalkulator import tambah, kurang, kali, bagi
# Parametrize sederhana โ satu parameter
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(1.5, 2.5, 4.0),
(-5, -3, -8),
])
def test_tambah_parametrize(a, b, expected):
assert tambah(a, b) == expected
# Parametrize untuk semua operasi
@pytest.mark.parametrize("op_func, a, b, expected", [
(tambah, 10, 5, 15),
(kurang, 10, 5, 5),
(kali, 10, 5, 50),
(bagi, 10, 5, 2.0),
])
def test_operasi_matematika(op_func, a, b, expected):
assert op_func(a, b) == expected
# Parametrize untuk test error
@pytest.mark.parametrize("a, b", [
(10, 0),
(100, 0),
(-5, 0),
])
def test_bagi_nol_parametrize(a, b):
with pytest.raises(ValueError):
bagi(a, b)
# Parametrize dengan ID custom (untuk output yang lebih jelas)
@pytest.mark.parametrize("input_str, expected", [
pytest.param("hello", "HELLO", id="lowercase"),
pytest.param("WORLD", "WORLD", id="uppercase"),
pytest.param("Hello World", "HELLO WORLD", id="mixed"),
pytest.param("123", "123", id="numbers"),
pytest.param("", "", id="empty"),
], ids=str)
def test_string_upper(input_str, expected):
assert input_str.upper() == expected
# Parametrize kombinasi (cartesian product)
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_kombinasi(x, y):
"""Akan dijalankan 6 kali (3 x 2 kombinasi)."""
assert x * y > 0
5. Markers: Mengelompokkan Test
Markers adalah decorator yang digunakan untuk memberikan metadata pada test. Marker memungkinkan Anda menjalankan sekelompok test tertentu, skip test, atau menandai test yang expected fail.
import pytest
import sys
# Marker bawaan pytest
# @pytest.mark.skip โ Skip test
@pytest.mark.skip(reason="Belum diimplementasikan")
def test_fitur_baru():
assert True
# @pytest.mark.skipif โ Skip kondisional
@pytest.mark.skipif(sys.platform == "win32", reason="Tidak jalan di Windows")
def test_linux_only():
import os
assert os.name == 'posix'
# @pytest.mark.xfail โ Expected failure
@pytest.mark.xfail(reason="Bug #123 belum diperbaiki")
def test_known_bug():
assert 1 + 1 == 3 # Diharapkan gagal
# Custom markers โ didaftarkan di pytest.ini atau pyproject.toml
# [tool.pytest.ini_options]
# markers = [
# "slow: tests that run slowly",
# "integration: integration tests",
# "smoke: smoke tests for quick validation",
# ]
@pytest.mark.slow
def test_komputasi_berat():
"""Test yang lambat, dijalankan hanya saat CI."""
import time
time.sleep(2)
assert True
@pytest.mark.integration
def test_database_integration():
"""Test yang butuh database."""
# Simulasi test database
assert True
@pytest.mark.smoke
def test_aplikasi_berjalan():
"""Smoke test โ verifikasi dasar."""
assert 1 + 1 == 2
# Menjalankan test berdasarkan marker:
# pytest -m slow โ Jalankan test @slow
# pytest -m "not slow" โ Skip test @slow
# pytest -m "integration or smoke" โ Jalankan keduanya
# pytest -m "not slow and not integration" โ Kecuali keduanya
Konfigurasi pytest
# pyproject.toml โ Konfigurasi pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
markers = [
"slow: Test yang berjalan lambat",
"integration: Test integrasi dengan layanan eksternal",
"smoke: Smoke test untuk validasi cepat",
]
filterwarnings = [
"ignore::DeprecationWarning",
]
6. Mocking: Mengeksperimen Dependensi
Mocking adalah teknik mengganti dependensi eksternal (API, database, file system) dengan objek palsu (mock) yang perilakunya bisa dikontrol. Ini memungkinkan test berjalan cepat, terisolasi, dan tidak tergantung layanan eksternal.
# services.py โ Kode yang menggunakan dependensi eksternal
import requests
from datetime import datetime
def get_cuaca(kota):
"""Mengambil data cuaca dari API eksternal."""
response = requests.get(f"https://api.cuaca.com/{kota}")
if response.status_code == 200:
data = response.json()
return {
'kota': kota,
'suhu': data['temperature'],
'kondisi': data['condition']
}
return None
def get_waktu_server():
"""Mengambil waktu dari server."""
return datetime.now()
def simpan_ke_database(data):
"""Menyimpan data ke database."""
# Simulasi database write
from database import db
db.insert(data)
return True
def proses_pesanan(pesanan):
"""Proses pesanan dengan validasi stok dan pembayaran."""
stok = get_stok_barang(pesanan['barang_id'])
if stok < pesanan['jumlah']:
return {'status': 'gagal', 'alasan': 'Stok tidak cukup'}
hasil_bayar = proses_pembayaran(pesanan['total'])
if not hasil_bayar['sukses']:
return {'status': 'gagal', 'alasan': 'Pembayaran gagal'}
return {'status': 'berhasil', 'order_id': hasil_bayar['order_id']}
# test_mocking.py โ Menggunakan mock
import pytest
from unittest.mock import patch, MagicMock, Mock
from services import get_cuaca, get_waktu_server, proses_pesanan
# Mock dengan @patch decorator
@patch('services.requests.get')
def test_get_cuaca_berhasil(mock_get):
"""Test get_cuaca dengan mock API response."""
# Setup mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
'temperature': 28,
'condition': 'Cerah'
}
mock_get.return_value = mock_response
# Jalankan fungsi
hasil = get_cuaca('Jakarta')
# Verifikasi
assert hasil['kota'] == 'Jakarta'
assert hasil['suhu'] == 28
assert hasil['kondisi'] == 'Cerah'
# Verifikasi mock dipanggil dengan benar
mock_get.assert_called_once_with('https://api.cuaca.com/Jakarta')
@patch('services.requests.get')
def test_get_cuaca_gagal(mock_get):
"""Test get_cuaca saat API gagal."""
mock_response = MagicMock()
mock_response.status_code = 500
mock_get.return_value = mock_response
hasil = get_cuaca('Jakarta')
assert hasil is None
# Mock dengan context manager
def test_get_waktu():
"""Test get_waktu dengan mock waktu."""
with patch('services.datetime') as mock_datetime:
mock_datetime.now.return_value = datetime(2026, 6, 25, 10, 30)
waktu = get_waktu_server()
assert waktu.year == 2026
assert waktu.month == 6
# Mock dengan patch.object
def test_proses_pesanan_stok_kurang():
"""Test pesanan gagal karena stok kurang."""
with patch('services.get_stok_barang', return_value=5):
with patch('services.proses_pembayaran') as mock_bayar:
pesanan = {'barang_id': 1, 'jumlah': 10, 'total': 500000}
hasil = proses_pesanan(pesanan)
assert hasil['status'] == 'gagal'
assert 'Stok tidak cukup' in hasil['alasan']
mock_bayar.assert_not_called() # Pembayaran tidak dipanggil
# Mock dengan side_effect (simulasi error)
@patch('services.requests.get')
def test_api_timeout(mock_get):
"""Test saat API timeout."""
mock_get.side_effect = TimeoutError("Connection timeout")
with pytest.raises(TimeoutError):
get_cuaca('Jakarta')
Saat menggunakan @patch, patch target harus di tempat modul menggunakannya, bukan di tempat modul didefinisikan. Misalnya: jika services.py mengimpor requests, maka patch 'services.requests.get', bukan 'requests.get'.
7. Test Coverage
Test coverage mengukur seberapa besar persentase kode yang teruji oleh test. Coverage membantu menemukan bagian kode yang belum memiliki test.
# Instal pytest-cov pip install pytest-cov # Jalankan test dengan coverage pytest --cov=. --cov-report=term-missing # Output: # ---------- coverage: platform linux, python 3.12 ---------- # Name Stmts Miss Cover Missing # ------------------------------------------------- # kalkulator.py 12 1 92% 11 # services.py 20 5 75% 25-30 # ------------------------------------------------- # TOTAL 32 6 81% # Generate HTML report pytest --cov=. --cov-report=html # Buka htmlcov/index.html di browser # Set minimum coverage (gagal jika di bawah 80%) pytest --cov=. --cov-fail-under=80 # Coverage untuk file tertentu pytest --cov=kalkulator --cov-report=term-missing
Konfigurasi Coverage
# pyproject.toml
[tool.coverage.run]
source = ["."]
omit = [
"tests/*",
"venv/*",
"*/migrations/*",
"conftest.py",
]
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
8. Test-Driven Development (TDD)
TDD adalah metodologi pengembangan di mana Anda menulis test sebelum menulis kode produksi. Siklus TDD dikenal sebagai Red-Green-Refactor.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ SIKLUS TDD โ โ โ โ โโโโโโโโโโโโ โ โ โ 1. RED โ Tulis test yang GAGAL โ โ โ (Gagal) โ (kode belum ada) โ โ โโโโโโฌโโโโโโ โ โ โ โ โ โผ โ โ โโโโโโโโโโโโ โ โ โ 2. GREEN โ Tulis kode MINIMAL agar test lulus โ โ โ (Lulus) โ (tidak lebih, tidak kurang) โ โ โโโโโโฌโโโโโโ โ โ โ โ โ โผ โ โ โโโโโโโโโโโโ โ โ โ 3. REFA- โ Perbaiki struktur kode โ โ โ CTOR โ (pastikan test masih lulus) โ โ โโโโโโฌโโโโโโ โ โ โ โ โ โโโโโโโโโโโโบ Ulangi siklus โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Contoh TDD: Membuat Class Keranjang Belanja
# test_keranjang.py โ Tulis test DULU (akan gagal karena class belum ada)
import pytest
from keranjang import Keranjang, Produk
class TestKeranjang:
@pytest.fixture
def keranjang(self):
return Keranjang()
@pytest.fixture
def produk_laptop(self):
return Produk(id=1, nama="Laptop", harga=10000000)
@pytest.fixture
def produk_mouse(self):
return Produk(id=2, nama="Mouse", harga=150000)
def test_keranjang_awalnya_kosong(self, keranjang):
assert keranjang.jumlah_item() == 0
assert keranjang.total() == 0
def test_tambah_produk(self, keranjang, produk_laptop):
keranjang.tambah(produk_laptop)
assert keranjang.jumlah_item() == 1
def test_tambah_produk_dengan_jumlah(self, keranjang, produk_mouse):
keranjang.tambah(produk_mouse, jumlah=3)
assert keranjang.jumlah_item() == 3
def test_total_harga(self, keranjang, produk_laptop, produk_mouse):
keranjang.tambah(produk_laptop)
keranjang.tambah(produk_mouse, jumlah=2)
assert keranjang.total() == 10000000 + (150000 * 2)
def test_hapus_produk(self, keranjang, produk_laptop):
keranjang.tambah(produk_laptop)
keranjang.hapus(produk_laptop.id)
assert keranjang.jumlah_item() == 0
def test_hapus_produk_tidak_ada(self, keranjang):
with pytest.raises(ValueError, match="Produk tidak ditemukan"):
keranjang.hapus(999)
def test_produk_string(self, produk_laptop):
assert str(produk_laptop) == "Laptop (Rp 10,000,000)"
# keranjang.py โ Implementasi agar test lulus
class Produk:
def __init__(self, id, nama, harga):
self.id = id
self.nama = nama
self.harga = harga
def __str__(self):
return f"{self.nama} (Rp {self.harga:,.0f})"
class Keranjang:
def __init__(self):
self._items = {} # {produk_id: {'produk': ..., 'jumlah': ...}}
def tambah(self, produk, jumlah=1):
if produk.id in self._items:
self._items[produk.id]['jumlah'] += jumlah
else:
self._items[produk.id] = {'produk': produk, 'jumlah': jumlah}
def hapus(self, produk_id):
if produk_id not in self._items:
raise ValueError("Produk tidak ditemukan")
del self._items[produk_id]
def jumlah_item(self):
return sum(item['jumlah'] for item in self._items.values())
def total(self):
return sum(
item['produk'].harga * item['jumlah']
for item in self._items.values()
)
# Menjalankan:
# pytest test_keranjang.py -v
# Semua 7 test seharusnya PASSED โ
9. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang pytest: