Pernah ngalamin production down gara-gara satu function yang "sebenarnya sederhana" malah bikin error? Saya pernah. Aplikasi berjalan baik di lokal, tapi pas deploy, logic validasi yang kelihatannya trivial malah ngelempar exception yang nggak terduga. Saat itu saya cuma nge-test manual lewat browser, "kayaknya udah ocoy" pikirku. Ternyata edge case yang saya skip saat development jadi masalah di production. Dari situ saya mulai serius nulis test, dan akhirnya milih pytest.
Kenapa pytest dan bukan unittest bawaan Python? Karena pytest bikin nulis test itu menyenangkan. Nggak perlu inheritance, nggak perlu camelCase method, dan assertion-nya cuma pakai assert polos. Tapi yang bikin saya betah adalah fixture dan mocking-nya. Dua fitur ini yang akan kita bahas tuntas di artikel ini.
Apa Itu Pytest dan Kenapa Bukan unittest?
Python sudah punya unittest di standard library. Bisa dipakai tanpa install apapun. Tapi kalau kamu pernah nulis test pakai unittest, tahu rasanya: class inheritance, method setUp dan tearDown, assertion kayak self.assertEqual(a, b) yang panjang banget. Pytest bikin semuanya lebih Pythonic.
Instalasi-nya simpel:
pip install pytest
Bandingkan cara nulis test antara unittest dan pytest:
# unittest style -- verbos
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
# pytest style -- bersih dan langsung
def test_add():
calc = Calculator()
assert calc.add(2, 3) == 5
Begitu lihat perbandingannya, pilihan jadi jelas. Pytest lebih ringkas, lebih readable, dan nggak butuh boilerplate. Assertion pakai assert native Python, jadi kalau kamu sudah bisa Python, kamu sudah bisa nulis test pakai pytest.
Struktur Project yang Rapi
Sebelum nulis test, atur struktur folder dulu. Ini penting biar pytest bisa discover test file otomatis dan import-nya nggak berantakan:
myproject/
|-- src/
| `-- calculator.py
|-- tests/
| |-- conftest.py
| |-- test_calculator.py
| `-- test_user.py
|-- pyproject.toml
`-- requirements.txt
File conftest.py adalah tempat kamu naruh shared fixture. Pytest otomatis load file ini tanpa perlu import manual. Ini salah satu magic yang bikin pytest produktif banget untuk project skala menengah ke atas.
Unit Test Dasar
Mari kita mulai dengan contoh nyata. Bikin file calculator.py:
# src/calculator.py
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("Nggak bisa bagi nol, bro")
return a / b
def factorial(self, n):
if n < 0:
raise ValueError("Faktorial angka negatif? Nggak ada")
if n == 0:
return 1
return n * self.factorial(n - 1)
Sekarang test-nya:
# tests/test_calculator.py
from src.calculator import Calculator
def test_add():
calc = Calculator()
assert calc.add(2, 3) == 5
assert calc.add(-1, 1) == 0
assert calc.add(0, 0) == 0
def test_add_float():
calc = Calculator()
assert calc.add(0.1, 0.2) == 0.3 # ini akan fail!
def test_divide():
calc = Calculator()
assert calc.divide(10, 2) == 5
def test_divide_by_zero():
calc = Calculator()
import pytest
with pytest.raises(ValueError, match="bagi nol"):
calc.divide(10, 0)
def test_factorial():
calc = Calculator()
assert calc.factorial(0) == 1
assert calc.factorial(5) == 120
Ada satu test yang sengaja saya tulis untuk fail: test_add_float. Floating point di Python (dan hampir semua bahasa) punya masalah presisi. 0.1 + 0.2 di Python hasilnya 0.30000000000000004, bukan 0.3. Ini bukan bug, ini cara floating point bekerja di komputer. Test ini mengajar kamu untuk selalu hati-hati dengan float.
Untuk fix test itu, pakai pytest.approx:
def test_add_float():
calc = Calculator()
assert calc.add(0.1, 0.2) == pytest.approx(0.3)
Jalankan semua test:
pytest tests/ -v
Flag -v (verbose) menampilkan nama setiap test yang dijalankan. Pas development, saya selalu pakai flag ini biar tahu test mana yang fail.
Fixture - Setup yang Bisa Dipakai Ulang
Bayangkan kamu punya 10 test function yang semua butuh instance Calculator. Tanpa fixture, kamu instantiate di setiap function. Repot dan boros. Fixture solve masalah ini.
# tests/conftest.py
import pytest
from src.calculator import Calculator
@pytest.fixture
def calc():
"""Bikin instance Calculator baru untuk setiap test."""
return Calculator()
@pytest.fixture
def calc_with_history():
"""Calculator yang sudah punya beberapa operasi."""
c = Calculator()
c.add(1, 2)
c.add(3, 4)
return c
Sekarang di test file, cukup pass nama fixture sebagai parameter:
# tests/test_calculator.py
def test_add(calc):
assert calc.add(2, 3) == 5
def test_divide(calc):
assert calc.divide(10, 2) == 5
def test_factorial(calc):
assert calc.factorial(5) == 120
Default-nya, fixture jalan sekali per test function. Kalau kamu punya 10 test yang pakai fixture calc, pytest bikin 10 instance berbeda. Ini behavior yang aman karena setiap test dapat state yang bersih.
Tapi kadang kamu mau setup yang mahal (koneksi database, misalnya) cuma dijalankan sekali untuk semua test. Pakai scope:
@pytest.fixture(scope="session")
def db_connection():
"""Koneksi DB sekali untuk seluruh test session."""
conn = create_connection("postgresql://localhost/testdb")
yield conn # pakai yield, bukan return
conn.close() # cleanup setelah semua test selesai
yield di sini penting banget. Semua yang ada setelah yield jadi teardown logic. Jadi kalau kamu buka koneksi DB sebelum yield, dan tutup setelahnya, pytest otomatis handle cleanup bahkan kalau test fail. Scope yang tersedia: function (default), class, module, package, session.
Parametrize - Test dengan Banyak Input
Salah satu fitur pytest yang paling hemat waktu adalah @pytest.mark.parametrize. Daripada nulis 5 test function untuk 5 input berbeda, kamu tulis satu function dengan data yang di-loop:
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(-5, -3, -8),
])
def test_add_multiple(calc, a, b, expected):
assert calc.add(a, b) == expected
Pytest otomatis generate 5 test case dari satu function. Kalau salah satu input fail, pytest kasih tahu input mana yang bermasalah. Ini sangat berguna untuk edge case testing kamu tinggal tambah data ke list tanpa nulis function baru.
Mocking - Test Tanpa Dependency Eksternal
Mocking adalah teknik untuk mengganti dependency eksternal (API call, database, file system) dengan object palsu yang behavior-nya bisa kamu kontrol. Kenapa penting? Karena test yang butuh internet atau DB akan lambat, flaky, dan susah di-debug kalau gagal.
Bayangkan kamu punya function yang panggil API eksternal:
# src/weather.py
import requests
class WeatherService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.weatherapi.com/v1"
def get_temperature(self, city):
url = f"{self.base_url}/current.json"
params = {"key": self.api_key, "q": city}
response = requests.get(url, params=params)
response.raise_for_status()
data = response.json()
return data["current"]["temp_c"]
Tanpa mocking, setiap kali test jalan, kamu hit API asli. Kalau API down, test fail. Kalau rate limit kepenuhan, test fail. Kalau internet putus, test fail. Semua failure ini bukan karena kode kamu salah, tapi karena dependency eksternal. Mocking solve ini:
# tests/test_weather.py
from unittest.mock import patch, Mock
from src.weather import WeatherService
@patch("src.weather.requests.get")
def test_get_temperature(mock_get):
# Arrange - setup mock response
mock_response = Mock()
mock_response.json.return_value = {
"current": {"temp_c": 28.5}
}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
# Act
service = WeatherService("fake-api-key")
temp = service.get_temperature("Jakarta")
# Assert
assert temp == 28.5
mock_get.assert_called_once()
@patch("src.weather.requests.get")
def test_get_temperature_api_error(mock_get):
mock_response = Mock()
mock_response.raise_for_status.side_effect = Exception("API Error 500")
mock_get.return_value = mock_response
service = WeatherService("fake-key")
import pytest
with pytest.raises(Exception, match="API Error 500"):
service.get_temperature("Bandung")
Perhatikan @patch("src.weather.requests.get"). Path-nya penting: kamu patch requests.get di modul src.weather, bukan di requests global. Ini adalah mistake yang sering bikin orang bingung. Kalau kamu patch requests.get langsung, pytest akan replace get di module requests asli, yang mungkin nggak efek ke kode kamu kalau import-nya pakai from requests import get.
Menggunakan pytest-mock untuk Syntax Lebih Bersih
Plugin pytest-mock menyediakan fixture mocker yang lebih Pythonic daripada unittest.mock.patch:
pip install pytest-mock
# tests/test_weather.py (dengan pytest-mock)
from src.weather import WeatherService
def test_get_temperature(mocker):
mock_response = mocker.Mock()
mock_response.json.return_value = {"current": {"temp_c": 28.5}}
mock_response.raise_for_status = mocker.Mock()
mocker.patch("src.weather.requests.get", return_value=mock_response)
service = WeatherService("fake-key")
temp = service.get_temperature("Jakarta")
assert temp == 28.5
Lebih clean. mocker otomatis cleanup mock setelah test selesai, jadi nggak ada side effect ke test lain. Ini salah satu keunggulan pytest-mock dibanding manual patch dengan decorator.
Coverage - Tahu Seberapa Banyak Kode Sudah Dites
Test coverage jawab pertanyaan: "berapa persen kode kamu sudah ada test-nya?" Ini bukan angka sakral yang harus 100%, tapi indikator bagus kalau ada code path yang belum tercover.
pip install pytest-cov
pytest tests/ --cov=src --cov-report=term-missing
Output-nya bakal nunjukkin file, total statement, dan baris mana yang belum dites. Fokus ke function penting dulu, bukan kejar 100% coverage. 80% coverage dengan test yang bermakna lebih baik daripada 100% dengan test yang cuma ngecek assert True.
Test Configuration dengan pyproject.toml
Biar pytest tahu dimana cari test dan source code, tambah konfigurasi di pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-v --tb=short"
markers = [
"slow: test yang lambat, skip dengan -m 'not slow'",
"integration: test yang butuh database",
]
Sekarang pytest aja tanpa argumen sudah cukup. Flag --tb=short bikin traceback lebih ringkas, dan marker slow/integration bikin kamu bisa filter test:
# Run semua test kecuali yang slow
pytest -m "not slow"
# Cuma run integration test
pytest -m "integration"
Tips Praktis dari Pengalaman
Setelah berbulan-bulan pakai pytest, ini hal-hal yang saya pelajari susah-susah:
- Nama test harus deskriptif.
test_calcnggak ngerti apa yang dites.test_divide_by_zero_raises_value_errorjelas. Kalau test fail, kamu tahu persis apa yang rusak tanpa baca kode. - Satu test, satu assertion (idealnya). Kalau test punya 5 assert dan yang ketiga fail, kamu nggak tahu hasil assert 4 dan 5. Pisah jadi test terpisah.
- Test behavior, bukan implementation. Jangan test "apakah function panggil internal helper X". Test "apakah output-nya benar untuk input Y". Internal bisa refactor, test nggak boleh rusak.
- Pakai
tmp_pathfixture untuk file I/O. Pytest sediakan direktori temporary otomatis yang dibersihkan setelah test. Nggak perlu manualos.remove. - Run test sebelum commit. Setup git pre-commit hook atau pakai
pytest --ff(failed first) biar test yang fail dijalankan duluan.
Kesimpulan
Pytest bikin testing di Python jauh lebih menyenangkan dibanding unittest. Assertion yang pakai assert native, fixture yang fleksibel, parametrize yang hemat kode, dan mocking yang clean semua ini bikin kamu lebih produktif nulis test. Dan test yang bagus bukan beban, tapi investasi. Setiap test yang kamu tulis hari ini adalah bug yang nggak akan production di hari esok.
Kalau kamu baru mulai, fokus ke unit test dulu. Belum perlu mock atau fixture yang complex. Tulis 5 test sederhana untuk function kamu yang paling penting. Setelah terbiasa, baru eksplor fixture dan mocking. Apa kamu sudah pakai pytest di project kamu, atau masih pakai unittest? Coba kasih tahu apa yang bikin kamu switch (atau nggak switch) di kolom komentar.