Akar Masalah: Flaky Test dan Kebocoran State Database

Flaky test adalah pengujian yang memberikan hasil berbeda (lulus atau gagal) tanpa ada perubahan pada kode aplikasi. Pada ekosistem Django, penyebab paling umum adalah isolasi database yang bocor antartes ketika runner dijalankan secara paralel atau acak via pytest-randomly.

Gejala klasiknya meliputi: pengujian lolos jika dijalankan secara mandiri, tetapi gagal ketika dieksekusi setelah pengujian lain. Dua penyebab utamanya: asumsi nilai auto-increment primary key (ID sequence mismatch) dan data sisa akibat transaksi yang tidak ter-rollback dengan benar.

Mekanisme Isolasi: TestCase vs TransactionTestCase

Django menyediakan dua class dasar pengujian database dengan mekanisme isolasi yang berbeda secara fundamental:

1. django.test.TestCase (Atomic Savepoint Rollback)

  • Mekanisme: Seluruh test dibungkus di dalam transaksi tingkat database menggunakan atomic(). Pada awal setiap test method, Django membuat savepoint. Pada tahap tearDown, Django mengeksekusi ROLLBACK TO SAVEPOINT.
  • Kelebihan: Sangat cepat karena tidak ada operasi DDL atau TRUNCATE tabel.
  • Batasan: Tidak dapat menguji kode yang memerlukan COMMIT database nyata, seperti callback transaction.on_commit() atau konkurensi multithread. Database sequences (seperti nilai ID auto-increment pada PostgreSQL) tidak direset saat rollback.

2. django.test.TransactionTestCase (Database Flush / Truncate)

  • Mekanisme: Tidak membungkus test dalam transaksi global. Test method mengeksekusi query dan commit secara langsung ke database pengujian. Pada tahap tearDown, Django membersihkan database dengan memanggil perintah flush (menjalankan TRUNCATE pada semua tabel yang termodifikasi).
  • Kelebihan: Memungkinkan pengujian transaksi nyata, commit boundaries, dan integritas level koneksi ganda.
  • Batasan: Jauh lebih lambat dibanding TestCase karena overhead operasi disk/DDL saat flush.

Anatomi Masalah: pytest-randomly dan Sequence Mismatch

Kesalahan fatal yang sering terjadi adalah melakukan hardcode terhadap primary key:

# ANTI-PATTERN: Rentan flaky test
def test_user_creation():
    user = User.objects.create(username="alice")
    assert user.id == 1  # Gagal jika dijalankan setelah test lain yang memakai sequence DB!

PostgreSQL, SQLite, dan MySQL tidak menurunkan kembali counter auto-increment saat transaksi di-rollback oleh TestCase. Jika sebuah test yang mewarisi TestCase berjalan setelah TransactionTestCase atau test lain yang telah mengonsumsi nilai sequence, ID yang dihasilkan berikutnya adalah 2, bukan 1. Gunakan atribut unik daripada ID statis:

# PATTERN: Bebas dependensi sequence
def test_user_creation_clean():
    user = User.objects.create(username="alice")
    assert user.username == "alice"
    assert user.pk is not None

Kapan Wajib Menggunakan TransactionTestCase?

Gunakan TransactionTestCase (atau fixture transactional_db pada pytest) hanya jika memenuhi kriteria berikut:

  1. Testing Database Locks / Multi-threading: Pengujian yang melibatkan select_for_update() atau memverifikasi race condition antar-worker/thread nyata.
  2. Testing Kompatibilitas Constraint DB Saat Commit: Constraint deferred yang hanya dievaluasi saat status commit penuh.
Catatan: Jika tujuannya hanya menguji eksekusi hook transaction.on_commit() (seperti dispatch worker Celery), jangan beralih ke TransactionTestCase. Gunakan context manager bawaan Django captureOnCommitCallbacks di dalam TestCase standar.

Contoh: Menguji on_commit Tanpa TransactionTestCase

from django.test import TestCase
from django.db import transaction
from myapp.tasks import process_order

class OrderTest(TestCase):
    def test_order_triggers_celery_task(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:
            # Kode di bawah memanggil transaction.on_commit(...)
            create_order_and_dispatch_task()
            
        # Callback dieksekusi dalam savepoint tanpa flush database
        assert len(callbacks) == 1

Konfigurasi Runner pytest-django

Jika menggunakan pytest, mapping kelas tersebut diimplementasikan melalui fixture: db setara dengan isolasi transaksi TestCase, dan transactional_db setara dengan TransactionTestCase.

Konfigurasikan pytest.ini untuk meminimalkan flakiness:

[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
python_files = tests.py test_*.py *_tests.py
addopts = 
    --strict-markers
    --randomly-seed=cache

Verifikasi DB State Hygiene

Gunakan assertion minimal berikut untuk membuktikan runner membersihkan state DB setelah eksekusi test yang menggunakan direct commit:

import pytest
from django.contrib.auth import get_user_model

User = get_user_model()

@pytest.mark.django_db(transaction=True)
def test_explicit_commit():
    User.objects.create(username="persistent_user")
    assert User.objects.count() == 1

@pytest.mark.django_db(transaction=False)
def test_state_isolation_check():
    # Assertion ini memastikan test_explicit_commit dibersihkan total oleh runner
    # Jika tabel tidak kosong, mekanisme flush teardown mengalami kebocoran.
    assert User.objects.count() == 0

Rule of thumb: Jadikan TestCase (atau @pytest.mark.django_db biasa) sebagai pilihan default 100%. Pindah ke TransactionTestCase hanya ketika dependensi konkurensi koneksi nyata tidak dapat dihindari.