Pendalaman pytest

Fixture, parametrize, dan mocking pada pytest untuk pengujian kode produksi.

Daftar isi

Fungsi uji sederhana dengan assert cukup untuk pengujian awal, tetapi kode produksi menuntut penyiapan data yang berulang, banyak kasus nilai, dan penggantian dependensi luar. pytest menyediakan tiga mekanisme untuk kebutuhan itu, yaitu fixture, parametrize, dan mock. Topik ini menerapkan ketiganya pada modul statistik nilai ujian beserta fungsi pengirim laporan, lalu menjalankan seluruh pengujian dari terminal dan dari dalam skrip.

Modul yang diuji dan pengujian pertamanya#

Modul yang diuji menyimpan dua fungsi. Fungsi rata_rata menghitung rata-rata daftar nilai dan menolak daftar kosong dengan ValueError, sedangkan predikat mengubah nilai angka menjadi huruf A sampai D dengan ambang yang tetap. Pengujian pertamanya memakai fungsi uji polos, dan pemeriksaan exception yang seharusnya muncul memakai pytest.raises.

python nilai.py
"""Fungsi statistik nilai ujian yang menjadi objek pengujian."""


def rata_rata(daftar_nilai):
    if not daftar_nilai:
        raise ValueError("daftar nilai kosong")
    return sum(daftar_nilai) / len(daftar_nilai)


def predikat(nilai):
    if nilai >= 85:
        return "A"
    if nilai >= 70:
        return "B"
    if nilai >= 55:
        return "C"
    return "D"
python test_nilai.py
# tidak-dijalankan: berkas pengujian dijalankan lewat perintah pytest
import pytest

from nilai import rata_rata


def test_rata_rata():
    assert rata_rata([80, 90, 100]) == 90


def test_rata_rata_kosong():
    with pytest.raises(ValueError):
        rata_rata([])
bash
# tidak-dijalankan: baris rangkuman pytest memuat durasi yang berubah antar eksekusi
python3 -m pytest -q test_nilai.py

Blok di atas tidak disertai keluaran karena baris rangkumannya memuat durasi eksekusi yang berubah-ubah. Bentuk keluarannya berupa dua titik keberhasilan diikuti baris 2 passed beserta durasinya, misalnya 2 passed in 0.01s. Fungsi uji yang menimbulkan exception di luar harapan dilaporkan dengan huruf F, traceback yang menjelaskan ketidakcocokan, serta baris FAILED pada rangkuman.

Pernyataan pytest.raises(ValueError) membungkus pemanggilan yang seharusnya menimbulkan exception, dan pengujian gagal apabila exception itu tidak muncul. Bentuk with ini menggantikan pola try dan except manual yang lebih panjang pada pengujian. Berkas uji hanya memuat fungsi test_ tanpa kelas maupun pemanggilan bawaan kerangka, karena pytest menemukan dan menjalankannya sendiri menurut pola nama berkas dan nama fungsinya.

Fixture untuk data uji bersama#

Fixture adalah fungsi bertanda @pytest.fixture yang nilainya disuntikkan ke fungsi uji melalui nama parameternya. Data yang sama dapat dipakai beberapa fungsi uji tanpa disalin ulang, dan pytest menyiapkan ulang fixture untuk setiap fungsi uji sehingga pengujian tetap saling lepas. Berkas conftest.py merupakan tempat khusus pytest, yaitu berkas yang dimuat otomatis dari direktori pengujian tanpa perlu diimpor.

python conftest.py
import shutil
from pathlib import Path

import pytest


@pytest.fixture
def daftar_nilai():
    """Lima nilai ujian yang dipakai bersama oleh beberapa pengujian."""
    return [80, 90, 100, 70, 60]


@pytest.fixture
def folder_laporan():
    """Folder sementara yang dihapus lagi setelah pengujian selesai."""
    folder = Path("laporan-uji")
    folder.mkdir(exist_ok=True)
    yield folder
    shutil.rmtree(folder)
python test_rata.py
# tidak-dijalankan: berkas pengujian dijalankan lewat perintah pytest
from nilai import rata_rata


def test_rata_rata_dengan_fixture(daftar_nilai):
    assert rata_rata(daftar_nilai) == 80


def test_folder_laporan_dibuat(folder_laporan):
    assert folder_laporan.exists()

Fixture pertama mengembalikan lima nilai, dan fungsi uji menerima hasilnya cukup dengan menamai parameternya sama dengan nama fixture. Fixture juga dapat memanggil fixture lain melalui parameternya, sehingga penyiapan bertingkat tetap dapat disusun tanpa menggandakan kode. Fixture kedua memakai bentuk yield, yaitu kode sebelum yield menjadi penyiapan, sedangkan kode sesudahnya menjadi pembersihan yang berjalan setelah fungsi uji selesai. Bentuk ini menjamin folder laporan-uji terhapus kembali apa pun hasil pengujian, sehingga satu pengujian tidak meninggalkan sisa bagi pengujian berikutnya.

Parametrize untuk banyak kasus#

Decorator @pytest.mark.parametrize menjalankan satu fungsi uji berulang kali untuk setiap pasangan argumen yang diberikan. Bentuk ini menghilangkan penulisan fungsi uji yang isinya sama namun nilainya berbeda, khususnya pada fungsi dengan banyak cabang seperti predikat.

python test_predikat.py
# tidak-dijalankan: berkas pengujian dijalankan lewat perintah pytest
import pytest

from nilai import predikat


@pytest.mark.parametrize("angka,harapan", [
    (100, "A"),
    (85, "A"),
    (84, "B"),
    (69, "C"),
    (55, "C"),
    (0, "D"),
])
def test_predikat(angka, harapan):
    assert predikat(angka) == harapan

Enam baris kasus mencakup batas atas dan batas bawah setiap predikat, misalnya 85 dan 84 yang berada di dua sisi ambang A. Argumen pertama decorator berupa string nama parameter yang dipisahkan koma, sedangkan argumen kedua berupa daftar kasus dalam bentuk tuple. pytest menjalankannya sebagai enam pengujian terpisah dengan nama yang memuat parameternya, seperti test_predikat[85-A], sehingga satu kasus yang gagal langsung terlihat tanpa menyisakan kasus lain yang tak teruji. Apabila ambang predikat berubah di kemudian hari, pengujian batas inilah yang pertama kali menangkap ketidaksesuaiannya.

Decorator ini juga dapat ditumpuk lebih dari satu pada fungsi uji yang sama, dan pytest menggabungkan seluruh kombinasi kasus dari kedua decorator tersebut. Nama parameter pada string decorator harus sama dengan nama parameter fungsi uji, sehingga kesalahan ejaan terlihat langsung sebagai error pengumpulan, bukan sebagai pengujian yang diam-diam tidak berjalan.

Mock untuk dependensi luar#

Fungsi yang mengirim permintaan HTTP tidak dapat diuji dengan data tetap, karena hasilnya bergantung pada server yang belum tentu tersedia. Mock mengganti objek tersebut dengan tiruan yang perilakunya ditetapkan oleh pengujian, dan tiruan itu juga mencatat pemanggilan yang diterimanya. Modul unittest.mock dari pustaka standar menyediakan fungsi patch untuk mengganti objek selama blok with berjalan.

python laporan.py
import requests


def kirim_laporan(judul):
    """Mengirim laporan ke server dan mengembalikan kode status responsnya."""
    respons = requests.post(
        "http://127.0.0.1:8901/laporan",
        json={"judul": judul},
        timeout=5,
    )
    respons.raise_for_status()
    return respons.status_code
python test_laporan.py
# tidak-dijalankan: berkas pengujian dijalankan lewat perintah pytest
import pytest
import requests

from unittest.mock import patch

from laporan import kirim_laporan


def test_kirim_laporan_sukses():
    with patch("laporan.requests.post") as post_tiruan:
        post_tiruan.return_value.status_code = 201
        assert kirim_laporan("laporan harian") == 201
        post_tiruan.assert_called_once()


def test_kirim_laporan_gagal():
    with patch("laporan.requests.post") as post_tiruan:
        post_tiruan.return_value.raise_for_status.side_effect = requests.HTTPError
        with pytest.raises(requests.HTTPError):
            kirim_laporan("laporan harian")

Target patch ditulis sebagai string laporan.requests.post, yaitu nama yang dicari oleh modul yang diuji, bukan nama pustaka asalnya. Pada pengujian pertama, return_value menetapkan kode status 201, sedangkan assert_called_once() memastikan fungsi kirim_laporan benar-benar mengirim tepat satu permintaan. Pada pengujian kedua, side_effect melempar requests.HTTPError dari raise_for_status, sehingga jalur kegagalan dapat diuji tanpa server yang benar-benar bermasalah. Pendekatan yang sama berlaku untuk antrean pesan, waktu sistem, atau sumber lain yang keadaannya tidak dapat dijamin saat pengujian berjalan.

Menjalankan seluruh pengujian#

Perintah pytest tanpa nama berkas mengumpulkan seluruh fungsi uji pada direktori kerja, dan pada contoh ini hasilnya berupa 12 passed beserta durasinya.

bash
# tidak-dijalankan: baris rangkuman pytest memuat durasi yang berubah antar eksekusi
python3 -m pytest -q

pytest juga dapat dipanggil dari dalam skrip melalui pytest.main, dan kode baliknya menyatakan hasil akhir tanpa perlu membaca teks keluaran.

python jalankan_uji.py
"""Menjalankan seluruh pengujian pytest dari dalam skrip dan merangkum hasilnya."""
import pytest

kode = pytest.main(["-p", "no:terminal", "-p", "no:cacheprovider", "."])
print("kode keluar pytest:", int(kode))
text Keluaran
kode keluar pytest: 0

Berkas tersebut mematikan plugin terminal pytest melalui -p no:terminal, sehingga keluaran tetap milik skripnya sendiri, kemudian mencetak kode balik pytest.main. Argumen -p no:cacheprovider menambah pembersihan lain, yaitu mencegah pytest membuat folder cache .pytest_cache pada direktori kerja. Kedua argumen itu tidak mengubah cara pengujian dijalankan, hanya merapikan keluaran dan sisa berkasnya. Kode 0 berarti seluruh pengujian lulus, sedangkan kode 1 berarti ada pengujian yang gagal, dan bentuk pemanggilan ini lazim dipakai pada skrip CI karena keputusannya cukup dari kode keluar.

Kode balik lain tercantum pada kelas ExitCode dalam dokumentasi pytest, misalnya 2 untuk pengujian yang terputus dan 4 untuk kesalahan pemakaian perintah. Dengan daftar itu, skrip pemanggil dapat membedakan kegagalan pengujian dari kesalahan perintahnya tanpa membaca teks keluaran sama sekali.

Kesalahan umum#

Kesalahan pertama adalah lupa menetapkan return_value pada tiruan. Objek Mock tanpa penugasan mengembalikan objek Mock untuk setiap atribut dan hasil pemanggilannya, sehingga raise_for_status tidak menimbulkan apa pun dan pengujian lulus dengan alasan yang salah. Menetapkan perilaku secara eksplisit sebagaimana pada contoh membuat hasil pengujian mengikuti perilaku yang dinyatakan, bukan perilaku bawaan tiruan yang kosong.

Kesalahan kedua adalah menguji detail implementasi, misalnya memastikan urutan pemanggilan metode internal, sehingga pengujian pecah setiap kali kode disunting meskipun perilakunya tidak berubah. Pernyataan pengujian sebaiknya menyasar hasil yang terlihat oleh pemanggil, seperti nilai balik dan kode status, sedangkan assert_called_once cukup dipakai untuk batas yang memang penting, misalnya memastikan permintaan tidak terkirim dua kali.

Kesalahan ketiga adalah memutasi nilai yang dikembalikan fixture. pytest menyiapkan ulang fixture untuk setiap fungsi uji pada scope bawaannya, tetapi apabila scope diperluas, mutasi pada nilai bersama terbawa ke pengujian berikutnya dan kegagalan pun muncul tanpa pola. Mengembalikan data yang tidak dimutasi oleh fungsi uji, atau menyalinnya terlebih dahulu, menjaga keterpisahan antarpengujian.

Ringkasan#

  • Fixture menyuntikkan data uji melalui nama parameter, dengan penyiapan dan pembersihan yang dirangkai oleh yield.
  • Berkas conftest.py dimuat pytest secara otomatis, sehingga fixture di dalamnya tersedia untuk seluruh berkas uji pada direktori itu.
  • @pytest.mark.parametrize menjalankan satu fungsi uji untuk setiap kasus, dengan nama pengujian yang memuat nilai parameternya.
  • unittest.mock.patch mengganti dependensi luar pada modul yang diuji, dengan return_value untuk hasil dan side_effect untuk exception.
  • pytest.main menjalankan pengujian dari dalam skrip, dan kode keluarnya cukup untuk menentukan kelulusan.

Referensi#