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 tahaptearDown, Django mengeksekusiROLLBACK TO SAVEPOINT. - Kelebihan: Sangat cepat karena tidak ada operasi DDL atau TRUNCATE tabel.
- Batasan: Tidak dapat menguji kode yang memerlukan
COMMITdatabase nyata, seperti callbacktransaction.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 perintahflush(menjalankanTRUNCATEpada semua tabel yang termodifikasi). - Kelebihan: Memungkinkan pengujian transaksi nyata, commit boundaries, dan integritas level koneksi ganda.
- Batasan: Jauh lebih lambat dibanding
TestCasekarena 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:
- Testing Database Locks / Multi-threading: Pengujian yang melibatkan
select_for_update()atau memverifikasi race condition antar-worker/thread nyata. - Testing Kompatibilitas Constraint DB Saat Commit: Constraint deferred yang hanya dievaluasi saat status commit penuh.
Catatan: Jika tujuannya hanya menguji eksekusi hooktransaction.on_commit()(seperti dispatch worker Celery), jangan beralih keTransactionTestCase. Gunakan context manager bawaan DjangocaptureOnCommitCallbacksdi dalamTestCasestandar.
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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!