Flaky test yang dipicu oleh order dependency sering kali berakar dari satu akar masalah: mutasi tidak sengaja pada data struktur bersama (shared mutable state). Dalam ekosistem Python, objek dict bersifat mutable secara default. Ketika sebuah dictionary dijadikan fixture atau konfigurasi global antartes, modifikasi nilai di satu unit test dapat membocorkan state (state leak) ke unit test lainnya tanpa disadari.

Artikel ini membahas penerapan frozendict—mengacu pada konsep perancangan mapping immutable seperti pada proposal PEP 814—untuk mengunci dictionary pengujian, mendeteksi pelanggaran akses tulis saat runtime, dan menghapus kebutuhan teardown boilerplate yang rentan galat.

Anatomi Masalah: State Leak dan Order Dependency

Pertimbangkan arsitektur pengujian di mana konfigurasi default didefinisikan sekali di tingkat modul pengujian atau conftest.py, lalu dibagikan ke beberapa test case via fixture berlingkup luas (session atau module scope) untuk efisiensi alokasi memori.

# conftest.py (Bermasalah)
import pytest

GLOBAL_API_CONFIG = {
    "endpoint": "https://api.internal.net/v1",
    "timeout": 30,
    "retries": 3,
    "headers": {"X-Environment": "staging"}
}

@pytest.fixture(scope="session")
def base_config():
    return GLOBAL_API_CONFIG

Jika satu pengujian memodifikasi dictionary tersebut secara in-place, pengujian berikutnya akan berjalan dengan konteks data yang telah tercemar.

# test_services.py
def test_custom_timeout_mutation(base_config):
    # Mutasi in-place tidak disengaja
    base_config["timeout"] = 999
    assert base_config["timeout"] == 999

def test_default_timeout(base_config):
    # Lolos jika dijalankan mandiri, gagal jika dijalankan setelah test_custom_timeout_mutation
    assert base_config["timeout"] == 30, f"Expected 30, got {base_config['timeout']}"

Ketika test suite dijalankan secara acak melalui plugin seperti pytest-random-order, test_default_timeout akan menghasilkan kegagalan sporadis (flaky). Urutan eksekusi menjadi penentu keberhasilan pengujian.

Mekanisme frozendict dan Prinsip PEP 814

Proposal PEP 814 mengusulkan antarmuka mapping immutable standar untuk runtime Python. Tujuannya adalah menyediakan tipe data mapping yang mengimplementasikan collections.abc.Mapping penuh, namun secara eksplisit menolak mutasi seperti __setitem__, __delitem__, pop(), atau update().

Di tingkat implementasi (menggunakan pustaka frozendict pihak ketiga yang kompatibel dengan protokol Python), setiap operasi penulisan dicegah langsung pada level runtime dengan melempar TypeError.

from frozendict import frozendict

immutable_config = frozendict({
    "endpoint": "https://api.internal.net/v1",
    "timeout": 30
})

# Operasi pembacaan valid (memenuhi collections.abc.Mapping)
assert immutable_config["timeout"] == 30

# Upaya mutasi memicu eror seketika
try:
    immutable_config["timeout"] = 10
except TypeError as exc:
    # frozendict memblokir assignment
    pass

Implementasi Fixture Imutabel pada Pytest

Mengubah payload mock dan konfigurasi dasar menjadi frozendict memungkinkan pendeteksian dini terhadap kode produksi atau fungsi pengujian yang secara keliru mengubah argumen input dictionary alih-alih membuat salinan baru.

Konfigurasi Global dan Payload Mock

Perlu diperhatikan bahwa imutabilitas pada level terluar tidak otomatis mencakup sub-dictionary bersarang (nested dict). Lakukan konversi rekursif atau deklarasi spesifik untuk menjamin struktur data benar-benar terkunci.

# test_fixtures.py
import pytest
from frozendict import frozendict

def freeze_recursive(data):
    if isinstance(data, dict):
        return frozendict({k: freeze_recursive(v) for k, v in data.items()})
    return data

@pytest.fixture(scope="session")
def immutable_api_config():
    raw_payload = {
        "endpoint": "https://api.internal.net/v1",
        "timeout": 30,
        "retries": 3,
        "headers": {"X-Environment": "staging"}
    }
    return freeze_recursive(raw_payload)

Strategi Verifikasi: Write Attempt Assertion

Dengan fixture di atas, pengujian dapat langsung memverifikasi bahwa kode layanan mematuhi prinsip fungsi murni (pure functions) tanpa efek samping pada argumen mapping yang diterimanya.

# test_client.py
import pytest

def client_worker(config: dict):
    # Contoh implementasi buruk di fungsi internal:
    # config["executed"] = True
    return f"Calling {config['endpoint']}"

def test_client_worker_does_not_mutate(immutable_api_config):
    # Jika client_worker melakukan in-place write, TypeError akan dilempar
    result = client_worker(immutable_api_config)
    assert "Calling" in result
    assert immutable_api_config["timeout"] == 30

Jika client_worker mencoba menulis ke immutable_api_config, runner pytest akan langsung menghentikan pengujian dengan stack trace akurat yang menunjuk baris mutasi spesifik, bukan kegagalan tak jelas pada test case lain beberapa menit kemudian.

Eliminasi Teardown Boilerplate

Solusi tradisional untuk mencegah state leak biasanya mengandalkan teardown manual via block yield pada fixture atau penggunaan deepcopy yang memakan komputasi:

# Pendekatan konvensional: Membutuhkan deepcopy atau cleanup manual
@pytest.fixture
def mutable_config_fixture():
    import copy
    # Overhead alokasi memori berulang setiap test dijalankan
    yield copy.deepcopy(GLOBAL_API_CONFIG)

Dengan mengunci struktur data menggunakan frozendict, siklus teardown menjadi redundan. Fixture dapat mempertahankan scope yang luas (session atau module) secara aman karena sifat imutabelnya menjamin objek berada pada status identik dari awal hingga akhir eksekusi tanpa risiko mutasi antar-test runner.

Trade-off dan Batasan Kompatibilitas

Meskipun frozendict memecahkan isu state leak secara struktural, pertimbangkan batasan berikut sebelum integrasi menyeluruh:

  • Protokol Mapping vs Konkret Dict: frozendict lolos pengecekan tipe berbasis abstraksi (isinstance(obj, collections.abc.Mapping)). Namun, pustaka pihak ketiga yang secara kaku memeriksa isinstance(obj, dict) atau validasi tipe C-extension native berpotensi menolak instance frozendict.
  • Performa Instansiasi: Penggunaan freeze_recursive pada struktur dictionary yang sangat besar (jutaan node) menimbulkan latensi pada fase setup awal test suite.
  • Nested Mutable Elements: Membungkus dictionary terluar tidak membuat elemen tipe list atau set di dalamnya menjadi imutabel secara otomatis. Objek list tetap harus dikonversi manual menjadi tuple, dan set menjadi frozenset untuk mencegah mutasi pada level anak.