Flaky test adalah test yang kadang gagal, kadang lolos, tanpa perubahan kode yang relevan. Di CodeIgniter 4, penyebab paling sering bukan framework-nya, melainkan test yang bergantung pada clock sistem, data bersama di database, urutan eksekusi, nilai acak, atau I/O yang tidak dikendalikan. Solusinya adalah membuat test deterministik: input tetap, waktu tetap, state terisolasi, dan dependency eksternal diganti dengan fake atau stub.
Artikel ini membahas pendekatan praktis untuk mengurangi flaky test di CodeIgniter 4 dengan fixture dan clock stabil. Fokusnya bukan teori umum testing, tetapi keputusan implementasi yang bisa langsung dipakai di project CI4: kapan memakai unit test, integration test, feature test, kapan menggunakan fixture atau seeding, bagaimana menjaga database tetap bersih, dan bagaimana menyelidiki test yang hanya gagal di CI.
Mengapa test menjadi flaky?
Sebelum memperbaiki, penting memahami sumber ketidakstabilan yang paling umum.
1. Waktu berubah saat test berjalan
Contoh paling sering adalah kode yang memanggil time(), date(), atau membuat objek tanggal langsung dari waktu saat ini. Test bisa gagal jika dieksekusi tepat di batas menit, hari, timezone, atau jika ada perbedaan jam antara mesin lokal dan runner CI.
2. Data bersama antar test
Jika satu test menulis ke database dan test lain membaca data yang sama tanpa reset state, hasil test bergantung pada urutan eksekusi. Ini sangat sering terjadi saat memakai database nyata untuk beberapa class test tanpa fixture yang terisolasi.
3. Urutan eksekusi tidak konsisten
Test yang hanya lolos jika dijalankan setelah test lain biasanya punya ketergantungan tersembunyi. Misalnya, cache belum dibersihkan, file sementara masih ada, singleton menyimpan state, atau service locator mengembalikan instance lama.
4. Nilai acak
Pemanggilan rand(), random_int(), UUID acak, atau generator data palsu yang tidak dikendalikan dapat membuat assertion berubah-ubah. Randomness berguna untuk variasi data, tetapi harus bisa direproduksi.
5. Ketergantungan I/O
HTTP call ke layanan eksternal, filesystem, cache server, email, queue, atau proses asinkron dapat menambah latensi, timeout, dan kondisi balapan. Test jadi sensitif terhadap lingkungan, bukan logika bisnis.
Strategi test di CodeIgniter 4: pisahkan unit, integrasi, dan feature test
Banyak flaky test muncul karena semua hal diuji di level yang sama. Kuncinya adalah memilih level test yang tepat.
Unit test
Unit test menguji logika kecil secara terisolasi. Dependency seperti database, waktu, HTTP client, atau generator random sebaiknya diganti fake, mock, atau stub. Unit test harus sangat cepat dan tidak menyentuh I/O nyata.
Pilih unit test jika:
- Anda menguji aturan bisnis, validasi, formatter, policy, atau service murni.
- Anda ingin test deterministik tanpa bootstrap penuh aplikasi.
- Anda perlu memastikan edge case waktu atau random bisa dikendalikan.
Integration test
Integration test menguji kerja sama beberapa komponen, misalnya model dengan database test, repository dengan query builder, atau service dengan konfigurasi framework. Di sini database test masih masuk akal, tetapi state harus diisolasi.
Pilih integration test jika:
- Anda perlu memverifikasi query, relasi data, migrasi, atau mapping model.
- Anda ingin menguji hasil interaksi dengan database sungguhan atau database test.
- Anda siap mengelola fixture, seeding, dan rollback state.
Feature test
Feature test menguji alur dari sisi HTTP: request, controller, filter, response, dan kadang interaksi database. Ini cocok untuk memastikan endpoint bekerja dari perspektif pengguna aplikasi.
Pilih feature test jika:
- Anda perlu memastikan route, middleware/filter, status code, dan payload response.
- Anda ingin menguji alur login, pembuatan resource, atau validasi request.
- Anda menerima biaya eksekusi yang lebih berat daripada unit test.
Aturan praktis: semakin tinggi level test, semakin besar peluang flaky jika dependency tidak dikendalikan. Karena itu, sebanyak mungkin logika bisnis sebaiknya diuji di unit test, lalu sisakan integration dan feature test untuk jalur yang memang perlu integrasi framework.
Membuat waktu stabil dengan abstraction untuk time provider
Sumber flaky paling umum adalah waktu. Cara paling aman adalah jangan memanggil waktu saat ini langsung di domain logic. Bungkus akses waktu dalam abstraction yang bisa diganti saat test.
Contoh interface clock
<?php
namespace App\Contracts;
interface Clock
{
public function now(): \DateTimeImmutable;
}
Implementasi production
<?php
namespace App\Services;
use App\Contracts\Clock;
class SystemClock implements Clock
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now');
}
}
Fake clock untuk test
<?php
namespace Tests\Support\Fakes;
use App\Contracts\Clock;
class FrozenClock implements Clock
{
public function __construct(private \DateTimeImmutable $now)
{
}
public function now(): \DateTimeImmutable
{
return $this->now;
}
}
Service yang memakai clock
<?php
namespace App\Services;
use App\Contracts\Clock;
class TokenService
{
public function __construct(private Clock $clock)
{
}
public function buildExpiry(int $minutes): string
{
return $this->clock
->now()
->modify("+{$minutes} minutes")
->format('Y-m-d H:i:s');
}
}
Unit test yang deterministik
<?php
namespace Tests\Unit;
use App\Services\TokenService;
use PHPUnit\Framework\TestCase;
use Tests\Support\Fakes\FrozenClock;
class TokenServiceTest extends TestCase
{
public function testBuildExpiryUsesFrozenTime()
{
$clock = new FrozenClock(new \DateTimeImmutable('2025-01-10 08:00:00'));
$service = new TokenService($clock);
$expiry = $service->buildExpiry(30);
$this->assertSame('2025-01-10 08:30:00', $expiry);
}
}
Pendekatan ini bekerja karena test tidak lagi bergantung pada jam sistem. Anda bisa menguji kasus batas seperti pergantian hari, token kedaluwarsa, atau toleransi timeout tanpa menunggu waktu nyata.
Kesalahan umum terkait waktu
- Mencampur timezone lokal dan UTC tanpa assertion yang jelas.
- Memanggil
new DateTimeImmutable('now')di banyak tempat sehingga sulit dikendalikan. - Membandingkan timestamp dengan toleransi terlalu sempit pada test integrasi atau feature.
- Mengandalkan
sleep()untuk menunggu kondisi waktu tertentu.
Fixture, seeding terisolasi, dan transaksi database
Jika test menyentuh database, tujuan utamanya adalah memastikan setiap test punya state awal yang diketahui. Ada tiga teknik yang umum dipakai: fixture, seeding terisolasi, dan transaksi.
Kapan memakai fixture
Fixture cocok ketika data awal perlu konsisten dan dipakai berulang, misalnya tabel users, roles, atau produk referensi untuk serangkaian test. Fixture sebaiknya kecil, jelas, dan hanya berisi data yang benar-benar dibutuhkan test.
Gunakan fixture jika:
- Data awal tidak sering berubah.
- Banyak test membutuhkan baseline yang sama.
- Anda ingin test mudah dibaca karena data awal eksplisit.
Kapan memakai seeding terisolasi
Seeding terisolasi cocok untuk integration atau feature test yang butuh state lebih lengkap. Seeder bisa dipanggil di setUp() atau helper khusus, lalu dibersihkan setelah test selesai.
Gunakan seeding terisolasi jika:
- Anda butuh beberapa tabel saling terkait.
- Data uji lebih nyaman dibangun lewat seeder daripada inline insert.
- Setiap class test punya dataset sendiri, bukan berbagi global state.
Kapan memakai transaksi database
Transaksi sangat berguna untuk menjaga test tetap bersih: mulai transaksi sebelum test, lalu rollback setelah test selesai. Ini cepat dan efektif selama kode yang diuji memakai koneksi database yang sama dan operasi yang dijalankan memang bisa dibungkus transaksi.
Trade-off:
- Transaksi cepat, tetapi tidak selalu cocok jika kode membuka koneksi lain, memakai proses terpisah, atau menjalankan fitur database yang tidak kompatibel dengan rollback sederhana.
- Reseed database lebih berat, tetapi lebih mendekati kondisi nyata dan lebih aman untuk test yang kompleks.
Contoh pola setup/teardown untuk database test
<?php
namespace Tests\Integration;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Database;
abstract class DatabaseTestCase extends CIUnitTestCase
{
protected $db;
protected function setUp(): void
{
parent::setUp();
$this->db = Database::connect();
$this->db->transBegin();
// Siapkan data minimal yang dibutuhkan test.
$this->db->table('users')->insert([
'id' => 1001,
'email' => '[email protected]',
'created_at' => '2025-01-10 08:00:00',
]);
}
protected function tearDown(): void
{
if ($this->db && $this->db->transStatus()) {
$this->db->transRollback();
}
parent::tearDown();
}
}
Pola ini membuat setiap test dimulai dari kondisi yang sama. Jika Anda memakai seeder, panggil seeder di setUp() dan pastikan cleanup terjadi di tearDown() atau dengan reset database test sesuai kebutuhan suite.
Prinsip penting untuk data test
- Jangan berbagi ID atau email yang sama antar test jika tidak perlu.
- Jangan mengandalkan auto-increment tertentu kecuali memang di-set eksplisit.
- Hindari assertion seperti “jumlah baris harus 1” jika tabel bisa berisi data lain dari setup global.
- Lebih aman memverifikasi record spesifik berdasarkan identifier yang Anda buat sendiri.
Mengendalikan urutan eksekusi, random value, dan I/O
Hindari ketergantungan pada urutan test
Setiap test harus bisa dijalankan sendirian. Jika sebuah test hanya lolos saat seluruh suite dijalankan, kemungkinan ada state tersembunyi yang tersisa.
Yang perlu diperiksa:
- Singleton atau service container menyimpan instance lama.
- Cache, session, atau file sementara tidak dibersihkan.
- Static property menyimpan state antar test.
- Test lain membuat data yang diam-diam dipakai ulang.
Jika Anda punya helper global atau service locator, pastikan ada cara reset di tearDown(). Untuk dependency penting, lebih aman membuat instance baru di setiap test.
Kendalikan random value
Untuk nilai acak, ada dua pendekatan yang aman:
- Injeksi generator melalui interface, sama seperti clock.
- Gunakan nilai tetap pada test jika randomness tidak sedang diuji.
Contohnya, daripada service membuat token acak sendiri, berikan dependency pembuat token. Saat production, pakai generator asli; saat test, pakai generator tetap.
<?php
namespace App\Contracts;
interface TokenGenerator
{
public function generate(): string;
}
<?php
namespace Tests\Support\Fakes;
use App\Contracts\TokenGenerator;
class FixedTokenGenerator implements TokenGenerator
{
public function __construct(private string $token)
{
}
public function generate(): string
{
return $this->token;
}
}
Dengan cara ini, assertion menjadi stabil karena token selalu sama di test.
Ganti I/O nyata dengan fake service
Jika kode mengirim email, memanggil API eksternal, atau menulis file, jangan jadikan dependency tersebut nyata di unit test. Buat abstraction dan fake.
Contoh dependency yang sebaiknya difake:
- HTTP client ke layanan pihak ketiga
- emailer
- filesystem adapter
- cache backend eksternal
- queue publisher
Pendekatan ini bekerja karena Anda menguji kontrak perilaku, bukan kestabilan jaringan atau disk. Untuk integration test, gunakan dependency nyata hanya jika memang itu yang ingin diverifikasi, dan tetap batasi ruang lingkupnya.
Contoh struktur test CodeIgniter 4 yang mudah dirawat
Struktur yang rapi membantu memisahkan level test dan mengurangi state yang tercampur.
tests/
├── Unit/
│ ├── Services/
│ │ └── TokenServiceTest.php
│ └── Domain/
├── Integration/
│ ├── Models/
│ ├── Repositories/
│ └── DatabaseTestCase.php
├── Feature/
│ ├── Auth/
│ └── Api/
└── Support/
├── Fakes/
│ ├── FrozenClock.php
│ └── FixedTokenGenerator.php
├── Seeders/
└── Helpers/
Struktur ini memudahkan Anda membuat aturan sederhana:
- Unit: tidak boleh I/O nyata.
- Integration: boleh database test, tetapi state harus terisolasi.
- Feature: fokus pada perilaku endpoint dan response, bukan menguji semua kombinasi logika bisnis.
Pola setup/teardown yang aman
<?php
namespace Tests\Feature\Api;
use CodeIgniter\Test\FeatureTestCase;
class UserApiTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
// Reset cache, siapkan fixture, atau bind fake service.
}
protected function tearDown(): void
{
// Bersihkan file temporary, cache, dan state statis bila ada.
parent::tearDown();
}
public function testGetUserReturns200()
{
$result = $this->get('/api/users/1001');
$result->assertStatus(200);
$result->assertSee('[email protected]');
}
}
Prinsipnya sederhana: semua yang dibuat di setUp() harus dianggap milik test itu sendiri dan dibersihkan setelah selesai.
Teknik membuat test deterministik
Berikut kebiasaan yang paling efektif untuk menurunkan flaky rate.
- Bekukan waktu dengan clock abstraction, bukan memanggil waktu sistem langsung.
- Gunakan fixture kecil dan eksplisit, bukan data besar yang sulit dipahami.
- Seed per test atau per class sesuai kebutuhan, bukan satu seed global untuk seluruh suite.
- Rollback transaksi jika cocok, agar database cepat kembali bersih.
- Pakai identifier tetap untuk data test: email, slug, atau UUID yang sudah ditentukan.
- Fake dependency I/O pada unit test dan sebagian besar integration test.
- Jangan pakai sleep untuk menunggu kondisi; ubah desain agar kondisi bisa dipicu atau diinjeksikan.
- Hindari assertion rapuh seperti urutan default record tanpa
ORDER BYyang jelas. - Pastikan timezone test konsisten antara lokal dan CI.
- Jangan berbagi file temporary dengan nama tetap antar test paralel atau antar proses.
Kesalahan umum yang membuat test kadang gagal di CI tetapi lolos lokal
CI sering mengeksekusi test lebih bersih, lebih cepat, atau justru lebih lambat daripada lokal. Perbedaan ini menyingkap asumsi tersembunyi.
Checklist investigasi
- Periksa ketergantungan waktu
Apakah test memakai jam sistem, timezone default, atau membandingkan waktu hingga detik/milidetik? - Periksa state database
Apakah data dari test lain bisa terbaca? Apakah test mengandalkan auto-increment atau jumlah record total? - Periksa urutan data query
Jika query tidak punyaORDER BY, urutan hasil bisa berbeda. - Periksa random value
Apakah token, string acak, atau data generator dipakai langsung dalam assertion? - Periksa filesystem dan cache
Apakah file temporary dibersihkan? Apakah cache lama masih terbaca? - Periksa environment CI
Timezone, konfigurasi database test, permission direktori, atau driver yang berbeda bisa memengaruhi hasil. - Periksa dependency eksternal
Apakah test diam-diam menghubungi API, SMTP, atau service lain? - Periksa static state dan singleton
Apakah ada object global yang menyimpan data dari test sebelumnya? - Periksa race condition
Apakah ada proses async, queue, atau penulisan file yang diasumsikan selesai seketika? - Jalankan test secara terpisah dan berulang
Jika gagal hanya setelah test tertentu, ada kebocoran state. Jika gagal acak, biasanya waktu, I/O, atau concurrency.
Tips debugging yang praktis
- Ulangi test yang sama berkali-kali untuk memastikan benar-benar flaky, bukan gagal tetap.
- Tambahkan logging sementara untuk nilai waktu, timezone, ID data, path file, dan query input penting.
- Kecilkan ruang masalah: matikan dependency eksternal satu per satu dengan fake.
- Jalankan class test secara mandiri, lalu jalankan bersama suite untuk melihat apakah ada kontaminasi state.
- Pastikan assertion fokus pada perilaku yang penting, bukan detail incidental yang bisa berubah.
Kapan memilih fixture, seed, transaksi, fake service, dan clock abstraction?
Ringkasnya, pilih berdasarkan sumber ketidakstabilan yang ingin Anda kendalikan.
- Clock abstraction: saat kode bergantung pada waktu saat ini atau masa berlaku.
- Fixture: saat butuh baseline data kecil yang konsisten dan mudah dibaca.
- Seeding terisolasi: saat test memerlukan data lintas tabel yang lebih realistis.
- Transaksi database: saat ingin reset state cepat dan seluruh operasi berada dalam satu koneksi yang kompatibel.
- Fake service: saat dependency eksternal tidak relevan dengan logika yang sedang diuji.
Tidak semua test harus memakai semua teknik. Justru test yang stabil biasanya memakai kombinasi paling sederhana yang cukup. Untuk unit test, clock abstraction dan fake service sering sudah cukup. Untuk integration test, tambahkan fixture atau seed yang terisolasi. Untuk feature test, batasi cakupan agar tidak berubah menjadi integration test raksasa yang sulit dirawat.
Penutup
Untuk mengurangi flaky test di CodeIgniter 4, fokuslah pada lima sumber utama: waktu, data bersama, urutan eksekusi, random value, dan I/O. Bekukan waktu dengan abstraction, isolasi data dengan fixture atau seed, gunakan transaksi saat sesuai, hilangkan ketergantungan urutan, dan ganti dependency eksternal dengan fake.
Jika ada satu prinsip yang paling penting, itu adalah ini: test harus dapat memegang kendali penuh atas input dan state-nya sendiri. Saat kontrol itu jelas, test menjadi deterministik, lebih cepat dipercaya, dan jauh lebih mudah di-debug saat CI mulai memperlihatkan kegagalan yang sebelumnya tersembunyi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!