Menggunakan Database yang Sudah Ada di Django Test
Setiap kali kamu menjalankan python manage.py test, Django diam-diam melakukan banyak hal di belakang layar sebelum baris kode test-mu yang pertama dieksekusi. Ia membuat database baru, menjalankan migrasi, membungkus tiap test dalam transaction, lalu menghapus semuanya begitu selesai. Mekanisme ini bekerja dengan baik untuk sebagian besar kasus, tapi begitu project membesar — ratusan test, migrasi yang berat, atau kebutuhan integration test ke sistem nyata — developer mulai mencari cara mempercepat atau memodifikasi perilaku default ini. Artikel ini membahas bagaimana Django benar-benar mengelola database test, opsi resmi yang tersedia untuk mempercepatnya, serta kapan (dan kenapa sebaiknya tidak) menggunakan database yang sudah ada di luar siklus test bawaan.
Bagaimana Django Test Database Bekerja
Django test runner tidak langsung menjalankan test-mu terhadap database yang dikonfigurasi di DATABASES. Ia membuat salinan terpisah khusus untuk testing, biasanya dengan prefix test_ di depan nama database asli.
flowchart TD
A[python manage.py test] --> B[Buat database test baru]
B --> C[Jalankan semua migrasi]
C --> D[Eksekusi test suite]
D --> E[Hapus database test]
E --> F[Tampilkan hasil test]Urutannya selalu sama:
- Django membaca konfigurasi
DATABASES['default']dan membuat database baru dengan namatest_<nama_db_asli> - Semua migrasi dijalankan dari awal di database test ini
- Test suite dieksekusi terhadap database yang sudah bersih dan ter-migrasi penuh
- Setelah seluruh test selesai, database test dihapus
Desain ini sengaja dibuat agar test reproducible — siapa pun yang menjalankan test yang sama, di mesin manapun, akan mendapat hasil yang identik karena selalu mulai dari skema database yang sama persis. Konsekuensinya, Django tidak menyediakan jalur resmi untuk langsung menjalankan test terhadap database production atau staging — ini adalah keputusan desain, bukan keterbatasan.
Transaction-based Test Isolation
Membuat database baru untuk seluruh test suite hanya menyelesaikan setengah masalah. Masalah berikutnya: bagaimana memastikan satu test tidak meninggalkan data yang memengaruhi test berikutnya? Di sinilah transaction wrapping berperan, dan ini adalah mekanisme yang sering luput dipahami meski jadi alasan utama kenapa test Django terasa “ajaib” — data selalu bersih di setiap test tanpa kamu perlu membersihkannya manual.
django.test.TestCase membungkus setiap method test dalam database transaction. Begitu method selesai, transaction di-rollback, bukan di-commit.
sequenceDiagram
participant Runner as Test Runner
participant DB as Database Test
Runner->>DB: BEGIN TRANSACTION
Runner->>DB: test_create_user() - INSERT data
Runner->>DB: assertEqual(...)
Runner->>DB: ROLLBACK
Note over DB: Data kembali kosong
Runner->>DB: BEGIN TRANSACTION
Runner->>DB: test_update_user() - INSERT, UPDATE
Runner->>DB: assertEqual(...)
Runner->>DB: ROLLBACK
Note over DB: Data kembali kosong lagiHasilnya, kamu bisa menulis test seperti ini tanpa khawatir data bocor ke test lain:
from django.test import TestCase
from myapp.models import Produk
class ProdukTest(TestCase):
def test_buat_produk(self):
Produk.objects.create(nama="Sepatu", harga=150000)
self.assertEqual(Produk.objects.count(), 1)
def test_produk_kosong_di_awal(self):
# Meskipun test di atas membuat 1 produk,
# di sini Produk.objects.count() tetap 0
self.assertEqual(Produk.objects.count(), 0)
Inilah kenapa setiap test method terasa berjalan di “database kosong” meski sebenarnya database fisiknya sama sepanjang test suite — transaction rollback yang membuat ilusi isolasi ini terjadi.
Transaction rollback tidak bekerja untuk hal-hal di luar transaction database, seperti:
- File yang ditulis ke disk
- Cache (Redis, Memcached) kecuali kamu reset manual
- Side effect dari
post_savesignal yang memanggil API eksternal
TestCase vs TransactionTestCase vs SimpleTestCase
Django menyediakan beberapa base class untuk test, dan masing-masing punya trade-off berbeda soal kecepatan, isolasi, dan fitur apa yang bisa diuji. Memilih base class yang salah adalah sumber bug test yang sulit dilacak — terutama saat kamu menguji sesuatu yang bergantung pada commit transaction sungguhan.
| Base Class | Isolasi | Kecepatan | Kapan Dipakai |
|---|---|---|---|
SimpleTestCase | Tidak ada akses DB sama sekali | Paling cepat | Test logic murni, form validation, utility function |
TestCase | Transaction + rollback per method | Cepat | Mayoritas test — default pilihan pertama |
TransactionTestCase | Truncate tabel setelah tiap method (commit sungguhan) | Lambat | Menguji on_commit(), multi-thread, raw SQL transaction |
LiveServerTestCase | Sama seperti TransactionTestCase + server HTTP nyala | Paling lambat | Selenium, end-to-end test browser |
TestCase cukup untuk hampir semua kasus karena rollback jauh lebih cepat daripada truncate tabel. Tapi ada satu jebakan klasik: kalau kode produksimu memakai transaction.on_commit() untuk menjadwalkan sesuatu (misalnya kirim email setelah order sukses), callback itu tidak akan pernah terpanggil di dalam TestCase karena transaction memang tidak pernah benar-benar commit.
# ANTI-PATTERN: testing on_commit() callback dengan TestCase biasa
from django.test import TestCase
from django.db import transaction
class OrderTest(TestCase):
def test_kirim_email_setelah_order(self):
with transaction.atomic():
order = Order.objects.create(status="paid")
transaction.on_commit(lambda: kirim_email_konfirmasi(order))
# callback on_commit TIDAK akan pernah jalan di sini,
# karena transaction outer milik TestCase belum pernah commit
# BENAR: gunakan TransactionTestCase agar commit sungguhan terjadi
from django.test import TransactionTestCase
class OrderTest(TransactionTestCase):
def test_kirim_email_setelah_order(self):
with transaction.atomic():
order = Order.objects.create(status="paid")
transaction.on_commit(lambda: kirim_email_konfirmasi(order))
# di sini commit sungguhan terjadi, callback on_commit terpanggil
Mulai dariTestCasesebagai default. Naik keTransactionTestCasehanya kalau test-mu butuh perilaku commit sungguhan — jangan sebaliknya, karenaTransactionTestCasejauh lebih lambat akibat truncate tabel di setiap method.
Mempercepat Test dengan --keepdb
Membuat dan menghapus database test di setiap run terasa ringan untuk project kecil, tapi begitu jumlah migrasi membengkak, langkah create-and-migrate ini bisa memakan waktu signifikan — kadang lebih lama dari eksekusi test itu sendiri. Django menyediakan flag resmi untuk masalah ini.
python manage.py test --keepdb
Dengan flag ini:
- Django mengecek apakah database test dengan nama
test_<nama_db>sudah ada - Jika sudah ada, Django langsung memakainya tanpa membuat ulang
- Migrasi yang sudah pernah dijalankan tidak diulang — Django hanya menjalankan migrasi baru yang belum tercatat
- Database tidak dihapus setelah test selesai, sehingga run berikutnya makin cepat
flowchart TD
A[python manage.py test --keepdb] --> B{Database test sudah ada?}
B -- Ya --> C[Pakai database existing]
B -- Tidak --> D[Buat database baru]
C --> E[Jalankan migrasi yang belum tercatat]
D --> E
E --> F[Eksekusi test]
F --> G[Database TIDAK dihapus]--keepdb aman dipakai sehari-hari karena database yang dipertahankan tetaplah database test — bukan database production. Satu-satunya hal yang perlu kamu ingat: kalau kamu mengubah migrasi secara destruktif (misalnya squash migration atau edit migration lama), database test yang di-keep bisa jadi tidak sinkron. Solusinya cukup hapus manual database test tersebut sekali, lalu jalankan ulang tanpa --keepdb untuk membuatnya fresh kembali.
Parallel Testing untuk Mempercepat Suite Besar
Selain --keepdb, Django juga punya mekanisme bawaan untuk menjalankan test secara paralel di banyak proses sekaligus — sangat berguna ketika test suite sudah berisi ribuan test dan dijalankan di CI dengan banyak CPU core.
python manage.py test --parallel=4
Setiap worker mendapat database test-nya sendiri, biasanya dengan suffix angka: test_mydb_1, test_mydb_2, test_mydb_3, dan seterusnya. Django membagi test class ke worker-worker ini secara otomatis.
flowchart TD
A[manage.py test --parallel=4] --> B[Worker 1: test_mydb_1]
A --> C[Worker 2: test_mydb_2]
A --> D[Worker 3: test_mydb_3]
A --> E[Worker 4: test_mydb_4]
B --> F[Hasil digabung]
C --> F
D --> F
E --> F--parallel bisa dikombinasikan dengan --keepdb untuk hasil paling cepat:
python manage.py test --parallel=4 --keepdb
Parallel testing mengasumsikan test-mu independen satu sama lain. Test yang bergantung pada urutan eksekusi (misalnya test B mengasumsikan data dari test A masih ada) akan gagal secara tidak konsisten ketika dijalankan paralel, karena keduanya bisa saja jatuh di worker dan database yang berbeda.
Apa yang Dimaksud “Database yang Sudah Ada”?
Setelah memahami mekanisme default, baru masuk akal membahas istilah “existing database” — karena istilah ini sebenarnya merujuk ke dua skenario yang sangat berbeda dan sering tertukar.
Database test yang sudah pernah dibuat — ini sebenarnya sudah dibahas di atas: database test hasil dari --keepdb yang masih tersisa di server. Memakainya kembali sepenuhnya aman karena tetap berada dalam siklus test resmi Django.
Database eksternal berisi data nyata — staging atau production, yang sudah dipakai aplikasi sungguhan dan berisi data riil pengguna. Inilah yang dimaksud kebanyakan orang ketika bertanya “bagaimana cara test pakai database yang sudah ada”, dan inilah yang berisiko.
Dua skenario ini butuh pendekatan dan level kehati-hatian yang jauh berbeda, jadi penting memastikan kamu sedang bicara tentang yang mana sebelum memutuskan caranya.
Menggunakan Database Eksternal untuk Test
Django, secara desain, tidak mendukung langsung penggunaan database eksternal sebagai target test. Tidak ada setting resmi seperti TEST_USE_EXISTING_DB = True. Tapi dalam kasus tertentu — integration test ke legacy system, atau read-only verification terhadap data nyata — beberapa tim memilih meng-override konfigurasi database secara manual menggunakan pytest-django.
Override ini dilakukan di conftest.py, dengan mengganti fixture django_db_setup agar Django langsung connect ke database yang dituju alih-alih membuat database test baru:
# conftest.py
import pytest
from django.conf import settings
@pytest.fixture(scope="session")
def django_db_setup():
settings.DATABASES['default'] = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'existing_db',
'USER': 'readonly_user',
'PASSWORD': 'secret',
'HOST': 'db.example.com',
'PORT': '5432',
}
# Catatan: dengan fixture ini di-override,
# pytest-django tidak akan membuat/menghapus database test
Begitu fixture ini aktif, seluruh test session terhubung langsung ke existing_db — bukan ke database test sementara. Perhatikan penggunaan readonly_user di kredensial; ini bukan kebetulan, melainkan mitigasi minimum yang wajib ada kalau kamu memilih pendekatan ini.
Pendekatan override ini hanya masuk akal untuk read-only test terhadap database eksternal. Jangan pernah menjalankan test yang melakukancreate(),update(),delete(), atau migrasi terhadap database yang di-override dengan cara ini — risikonya adalah kehilangan atau merusak data nyata secara permanen.
Risiko Menggunakan Database Existing untuk Test
Menjalankan test langsung di atas database eksternal punya beberapa risiko struktural yang tidak bisa dihindari, bukan sekadar masalah kehati-hatian individu developer:
- Data bisa berubah atau terhapus — test yang memanggil
.save()atau.delete()langsung memengaruhi data nyata, tanpa rollback otomatis sepertiTestCase - Test saling bergantung pada state data — hasil test bisa berbeda tergantung data apa yang kebetulan ada di database saat itu, membuat test tidak reproducible
- Tidak ada isolasi antar test — satu test bisa “meracuni” state untuk test lain yang berjalan setelahnya
- Sulit melakukan rollback — tidak ada mekanisme bawaan untuk mengembalikan database ke kondisi semula setelah test gagal di tengah jalan
- Migrasi bisa berjalan tanpa sengaja — kalau Django mendeteksi migrasi pending, ia bisa mencoba menjalankannya terhadap database yang sedang dipakai aplikasi production
Karena alasan-alasan inilah Django sejak awal memilih untuk tidak menyediakan jalur resmi ke pendekatan ini. Ini bukan keterbatasan framework, melainkan keputusan desain yang melindungi developer dari kesalahan yang sulit di-undo.
Alternatif yang Lebih Aman
Kalau motivasimu menggunakan database existing adalah ingin data yang realistis (bukan sekadar mempercepat test, yang sudah terjawab dengan --keepdb), ada beberapa pendekatan yang memberi hasil serupa tanpa risiko menyentuh data nyata.
Fixtures dari Data Existing
Ambil snapshot data dari database existing, simpan sebagai fixture, lalu load fixture itu ke database test setiap kali dibutuhkan.
# Ambil data dari database existing, simpan sebagai fixture
python manage.py dumpdata app.Produk --indent=2 > fixtures/produk.json
# Load fixture saat test dijalankan — otomatis masuk ke database test terisolasi
from django.test import TestCase
class ProdukTest(TestCase):
fixtures = ['produk.json']
def test_jumlah_produk_sesuai_fixture(self):
self.assertTrue(Produk.objects.exists())
Dengan cara ini, test tetap berjalan di database test yang ter-isolasi penuh — fixture hanya menyuntikkan data awal yang realistis, dan rollback TestCase tetap berlaku normal setelahnya.
Factory Pattern dengan factory_boy
Untuk data yang perlu bervariasi antar test (bukan sekadar dump statis), pola factory lebih fleksibel daripada fixture JSON yang kaku:
import factory
from myapp.models import Produk
class ProdukFactory(factory.django.DjangoModelFactory):
class Meta:
model = Produk
nama = factory.Sequence(lambda n: f"Produk {n}")
harga = factory.Faker('random_int', min=10000, max=500000)
# Pemakaian di test
class ProdukTest(TestCase):
def test_produk_punya_harga_positif(self):
produk = ProdukFactory()
self.assertGreater(produk.harga, 0)
Factory menghasilkan data baru setiap kali dipanggil, tetap di database test yang ter-isolasi, dan jauh lebih mudah disesuaikan per skenario test dibanding fixture statis.
Mocking untuk Unit Test Murni
Untuk logic yang sebenarnya tidak butuh database nyata — validasi, kalkulasi, transformasi data — mocking menghindari ketergantungan ke database sama sekali, baik test maupun production.
from unittest.mock import patch
from django.test import SimpleTestCase
class HitungDiskonTest(SimpleTestCase):
@patch('myapp.services.Produk.objects.get')
def test_hitung_diskon(self, mock_get):
mock_get.return_value.harga = 100000
hasil = hitung_diskon(produk_id=1, persen=10)
self.assertEqual(hasil, 90000)
SimpleTestCase bahkan melarang akses database sama sekali secara default — kalau test ini tidak sengaja memanggil ORM tanpa di-mock, Django akan melempar error eksplisit, bukan diam-diam connect ke database.
Custom Test Runner
Untuk kebutuhan sangat spesifik — misalnya mengganti urutan setup database atau menambahkan langkah verifikasi sebelum test jalan — kamu bisa membuat test runner sendiri dengan meng-override setup_databases dan teardown_databases dari DiscoverRunner. Pendekatan ini jarang benar-benar diperlukan dan menambah kompleksitas maintenance, jadi pertimbangkan dulu apakah --keepdb, fixtures, atau factory sudah cukup sebelum masuk ke jalur ini.
Tabel Ringkasan Strategi
| Kebutuhan | Pendekatan yang Direkomendasikan |
|---|---|
| Test standar sehari-hari | TestCase + database test bawaan Django |
| Mempercepat run berulang | --keepdb |
| Test suite besar di CI | --parallel dikombinasikan dengan --keepdb |
Menguji on_commit() atau multi-thread | TransactionTestCase |
| Data test yang realistis | Fixtures (dumpdata / loaddata) |
| Data bervariasi antar test | Factory pattern (factory_boy) |
| Logic murni tanpa DB | SimpleTestCase + mocking |
| Integration test ke sistem legacy (read-only) | Override pytest-django dengan readonly_user |
| Akses langsung ke database production | ❌ Tidak disarankan dalam kondisi apa pun |
Ringkasan
- Django selalu membuat database test terpisah secara default — proses create, migrate, run, destroy — agar test reproducible dan aman dari data production.
TestCasemembungkus tiap test method dalam transaction yang di-rollback, memberi isolasi otomatis tanpa perlu cleanup manual.- Pakai
TransactionTestCasehanya saat butuh commit sungguhan, misalnya untuk mengujitransaction.on_commit()— base class ini jauh lebih lambat karena truncate tabel di tiap method.--keepdbadalah cara resmi dan aman untuk mempercepat test berulang dengan mempertahankan database test antar run.--parallelmempercepat suite besar dengan membagi test ke banyak worker, masing-masing dengan database test sendiri — tapi mengasumsikan test-mu independen satu sama lain.- “Database existing” punya dua arti: database test lama (aman dipakai ulang) vs database eksternal berisi data nyata (berisiko tinggi).
- Override database eksternal lewat
pytest-djangohanya masuk akal untuk read-only test, dengan kredensial read-only sebagai mitigasi minimum.- Fixtures dan factory_boy memberi data yang realistis tanpa menyentuh database nyata, sambil tetap menikmati isolasi penuh dari
TestCase.- Hindari menjalankan test apapun langsung di atas database production — risikonya kehilangan data nyata jauh melebihi manfaat kecepatan yang didapat.