Pukul tujuh pagi, tiga ratus siswa menunggu di ruang lab. Guru sudah menyiapkan paket soal, proktor sudah membagikan nomor peserta, dan server hanya punya satu kesempatan untuk benar.
Aplikasi ujian adalah salah satu jenis perangkat lunak yang paling tidak toleran terhadap kesalahan. Bug di aplikasi kasir bisa dikoreksi dengan jurnal manual. Bug di aplikasi ujian berarti nilai siswa salah, sengketa dengan orang tua, dan ujian ulang yang tidak selalu bisa dijadwalkan.
Karena itu, mendesain aplikasi ujian bukan soal membuat tampilan timer yang cantik. Ini soal memilih di mana kebenaran disimpan, dan siapa yang berhak mengubahnya.
Saya memakai modul CBT di repo abdasis/buku-sekolah sebagai studi kasus, karena modul itu dibangun dengan sejumlah keputusan yang jarang dibahas di tutorial. Repo-nya sendiri adalah platform manajemen sekolah multi-tenant: Laravel 13, Inertia React, PostgreSQL, dengan isolasi data per sekolah lewat subdomain dan Row Level Security.
Mulai dari memisahkan bank soal dari ujian
Kesalahan desain paling awal biasanya menggabungkan soal langsung ke dalam ujian. Sekali ujian dibuat, soal menempel di situ, dan tidak bisa dipakai ulang tanpa menyalin manual.
Modul ini memisahkan tiga lapis:
| Lapis | Isi | Sifat |
|---|---|---|
| Bank soal | kumpulan soal per mata pelajaran dan tingkat | hidup, terus diperbarui |
| Soal | pertanyaan, tipe, tingkat kesulitan, opsi | bisa diimpor dari Excel |
| Ujian | konfigurasi: durasi, jumlah soal, aturan acak, KKM | dibuat dari bank |
Dengan pemisahan ini, satu bank soal bisa dipakai untuk ulangan harian, penilaian tengah semester, dan remedial, tanpa menyalin apa pun. Guru juga bisa memperbaiki salah ketik pada soal tahun lalu tanpa mengubah nilai siswa yang sudah lulus.
Perhatikan konsekuensinya: kalau soal boleh berubah kapan saja, maka nilai yang sudah tersimpan harus dilindungi dari perubahan itu. Di sinilah keputusan desain kedua masuk.
Snapshot: soal dibekukan saat siswa mulai
Ini bagian yang paling sering dilewatkan aplikasi ujian buatan sendiri.
Ketika siswa memulai ujian, modul ini tidak menyimpan referensi ke questions. Dia menyalin isi soal dan opsinya ke tabel snapshot: exam_question_snapshots dan exam_option_snapshots.
// SnapshotBuilder: menyalin soal ke snapshot attempt, sekaligus mengacak
$orderedQuestions = ($exam?->shuffle_questions && $seed)
? collect($this->shuffleSeeder->shuffle($questions->all(), $seed))
: $questions;
Manfaatnya tiga:
- Nilai historis tetap sah. Guru boleh memperbaiki soal besok, nilai siswa hari ini tetap mengacu pada teks yang benar-benar dia baca.
- Soal bisa dihapus tanpa merusak arsip. Snapshot menyimpan salinan, jadi penghapusan di bank tidak menghapus bukti ujian.
- Urutan dan opsi bisa dibekukan per siswa. Setelah urutan diacak, hasil acakan disimpan. Siswa yang me-refresh halaman tidak mendapat urutan baru.
Harga yang dibayar jelas: duplikasi data. Untuk ujian dengan 40 soal pilihan ganda dan 300 siswa, snapshot berarti puluhan ribu baris tambahan per sesi. Itu wajar untuk data yang harus bisa dipertanggungjawabkan bertahun-tahun.
Mengacak tanpa membuat urutan berubah setiap refresh
Mengacak soal terdengar sepele sampai kamu menghadapi dua masalah nyata: hasil acakan berubah setiap kali halaman dimuat, dan fungsi acak global di PHP tidak aman dipakai di server yang berjalan lama.
Modul ini memakai pengacakan deterministik berbasis seed:
// Fisher-Yates dari ujung ke depan, nilai acak dari crc32(seed:i)
for ($i = $count - 1; $i >= 1; $i--) {
$j = abs(crc32("{$seed}:{$i}")) % ($i + 1);
[$result[$i], $result[$j]] = [$result[$j], $result[$i]];
}
Seed-nya disimpan di baris attempt (exam_attempts.seed). Konsekuensinya:
- urutan soal dan opsi selalu sama untuk attempt yang sama, meski aplikasi restart
- tidak menyentuh state global
srandataumt_rand, jadi aman di Octane - dua siswa dengan seed berbeda mendapat urutan berbeda
Satu hal soal keamanan: seed tidak boleh ditebak siswa. Kalau seed bisa dibaca dari API, urutan soal bisa direkonstruksi. Simpan seed di server, jangan pernah kirim ke client.
Waktu harus milik server
Timer di layar siswa hanya tampilan. Kebenaran ada di kolom expires_at pada tabel attempt, dan penegakannya di server:
public function isExpired(Carbon $expiresAt, Carbon $now, int $bufferSeconds = 3): bool
{
return $now->gte($expiresAt->copy()->subSeconds($bufferSeconds));
}
Perhatikan bufferSeconds. Buffer tiga detik itu bukan kemalasan, tapi pengakuan bahwa jaringan punya latensi dan jam perangkat siswa bisa meleset. Tanpa buffer, jawaban terakhir yang dikirim tepat di detik terakhir akan sering ditolak, dan sengketa seperti itu sulit dijelaskan ke siapa pun.
Ada satu kolom lagi yang penting: auto_submitted. Kalau attempt ditutup oleh sistem karena waktu habis, kolom itu bernilai benar. Guru bisa membedakan siswa yang menekan kumpulkan sendiri dan siswa yang kehabisan waktu, tanpa perlu bertanya.
Autosave dan jawaban yang datang terlambat
Jaringan lab sekolah tidak selalu baik. Jawaban bisa terkirim dua kali, bisa datang setelah versi yang lebih baru, dan bisa tiba sesudah waktu habis.
Tabel answers menyimpan dua kolom waktu: client_changed_at dari perangkat siswa, dan server_saved_at dari server. Uniknya dijaga pada pasangan (exam_attempt_id, exam_question_snapshot_id), jadi satu soal hanya punya satu baris jawaban per attempt. Kiriman berulang menjadi operasi update, bukan duplikasi.
Resolusi konfliknya memakai aturan last-writer-wins berbasis waktu server:
if ($this->isNewer($existing, $clientChangedAt)) {
// server sudah punya versi lebih baru, tolak yang masuk
return ['accepted' => false, 'data' => $existing->toArray()];
}
Yang membuat ini bekerja bukan algoritmanya, tapi keputusan untuk menyimpan client_changed_at sejak awal. Tanpa kolom itu, server tidak punya cara membedakan jawaban yang baru dengan jawaban yang datang terlambat.
Token: satu kali pakai, dan itu harus atomik
Kebocoran paling umum di ujian berbasis komputer bukan dari celah teknis yang rumit, tapi dari tautan ujian yang diteruskan ke grup WhatsApp.
Modul ini menyediakan beberapa mode token, termasuk token sekali pakai dan token bergulir. Yang menarik bukan pilihan modenya, tapi cara token dikonsumsi:
Delegasi ke repository yang menggunakan single UPDATE query untuk menghindari race condition.
Satu query UPDATE ... WHERE token = ? AND session_id = ? AND student_id = ? AND used_at IS NULL, lalu cek apakah ada baris yang berubah. Kalau dua siswa menekan tombol masuk pada milidetik yang sama dengan token yang sama, hanya satu yang berhasil. Pola baca-lalu-tulis akan meloloskan keduanya, dan itu jenis bug yang baru ketahuan saat ujian berlangsung.
Menilai: terpisah dari menyajikan
Penilaian di modul ini tidak ditulis sebagai satu fungsi besar berisi if bertingkat. Ada registry grader per tipe soal:
| Tipe soal | Penilai |
|---|---|
| Pilihan ganda | MultipleChoiceGrader |
| Benar salah | TrueFalseGrader |
| Menjodohkan | MatchingGrader |
| Jawaban singkat | ShortAnswerGrader |
| Esai | masuk antrean grading_tasks untuk dinilai manusia |
Menambah tipe soal baru berarti menambah satu grader, bukan menyentuh logika yang sudah jalan. Soal esai tidak dipaksa dinilai otomatis: dia masuk antrean tugas penilaian, dengan graded_by dan feedback tersimpan per jawaban.
Skor akhir dihitung dari jawaban yang sudah dinilai, dibandingkan dengan skor maksimum dari snapshot, lalu dibandingkan dengan KKM ujian:
$totalScore = (float) $answers->whereNotNull('score')->sum('score');
$maxScore = (float) $answers->map(fn (Answer $a) => $a->snapshot?->points ?? 0)->sum();
$passing = (float) ($session->exam?->passing_score ?? 0);
Perhatikan bahwa max_score diambil dari poin di snapshot, bukan dari konfigurasi ujian saat ini. Kalau guru mengubah bobot soal setelah ujian, nilai yang sudah keluar tidak berubah.
Data untuk memperbaiki soal, bukan hanya menilai siswa
Satu hal yang membedakan aplikasi ujian yang matang: dia menyimpan statistik per butir soal (exam_question_stats). Dari situ guru bisa melihat soal mana yang dijawab benar oleh hampir semua siswa, soal mana yang dijawab salah oleh hampir semua siswa, dan soal mana yang tidak membedakan siswa pandai dan lemah.
Tanpa data ini, bank soal hanya bertambah, tidak pernah membaik. Dengan data ini, soal yang terlalu mudah atau ambigu bisa diperbaiki sebelum dipakai lagi tahun depan.
Isolasi antar sekolah
Karena satu instalasi melayani banyak sekolah, setiap tabel di modul ini membawa tenant_id, dan isolasinya tidak diserahkan kepada disiplin programmer:
Schema::create('exam_attempts', function (Blueprint $table): void {
$table->unsignedBigInteger('tenant_id');
$table->foreignId('exam_id')->constrained('exams')->cascadeOnDelete();
$table->unsignedBigInteger('student_id');
$table->string('status', 10)->default('ongoing');
$table->timestampTz('started_at');
$table->timestampTz('expires_at');
// ...
});
DB::statement('ALTER TABLE exam_attempts ENABLE ROW LEVEL SECURITY');
DB::statement("
CREATE POLICY tenant_isolation ON exam_attempts
USING (tenant_id = current_setting('app.tenant_id', true)::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::bigint)
");
Dengan FORCE ROW LEVEL SECURITY, bahkan query yang lupa menambahkan filter tetap terpotong oleh basis data. Untuk data nilai siswa, lapisan pengaman kedua seperti ini bukan kemewahan.
Yang bersifat operasional, bukan teknis
Ada beberapa tabel yang mungkin terasa berlebihan sampai kamu ikut mengawal ujian sungguhan:
| Kebutuhan | Solusi di modul |
|---|---|
| Siswa tidak salah ruang | cbt_rooms, classroom_room_slots, exam_participant_numbers |
| Pengawas per ruang | exam_proctors |
| Jadwal per sesi | exam_time_slots |
| Jejak aktivitas siswa | session_activity_logs, violations_count |
| Laporan hasil | exam_reports |
Nomor peserta bukan formalitas. Di sekolah, nomor itu yang menentukan siswa duduk di komputer mana, dan komputer mana yang menyimpan jawaban siapa. Sistem yang mengabaikan pemetaan ini akan menghadapi pertanyaan yang tidak bisa dijawab saat ada siswa yang komputernya mati di tengah ujian.
Trade-off yang harus kamu terima
Tidak ada desain tanpa biaya. Empat yang paling nyata:
Snapshot menambah ukuran basis data. Setiap attempt menyimpan salinan soal dan opsi. Untuk sekolah dengan ujian mingguan, ini bertambah cepat. Siapkan kebijakan arsip dan kompresi untuk data lama.
Pengacakan deterministik bisa direkonstruksi. Kalau seed bocor, urutan soal bisa dihitung. Simpan seed hanya di server.
Token bergulir membebani guru. Mode paling aman juga paling merepotkan. Banyak sekolah akhirnya memilih token sekali pakai per sesi, bukan per siswa.
Esai butuh SLA penilaian. Antrean penilaian tidak menilai dirinya sendiri. Kalau tidak ada tenggat internal, nilai rapor akan tertahan di tugas yang belum dinilai.
Checklist desain
Kalau kamu membangun aplikasi ujian dari nol, mulailah dari daftar ini:
- Pisahkan bank soal, soal, dan ujian.
- Bekukan soal ke snapshot saat attempt dimulai, termasuk poin per soal.
- Simpan seed pengacakan per attempt, dan jangan pernah kirim ke client.
- Simpan
expires_atdi server, dan tegakkan dengan buffer beberapa detik. - Catat
auto_submitteduntuk membedakan kumpul sendiri dan kehabisan waktu. - Simpan
client_changed_atdanserver_saved_atpada setiap jawaban. - Beri unique index pada pasangan attempt dan soal, supaya kiriman berulang jadi update.
- Konsumsi token dengan satu query atomik, bukan baca-lalu-tulis.
- Pisahkan penilai per tipe soal, dan taruh esai di antrean manual.
- Hitung nilai maksimum dari snapshot, bukan dari konfigurasi terbaru.
- Simpan statistik per butir soal untuk evaluasi bank soal.
- Pasang isolasi tenant di level basis data, bukan hanya di level query.
Penutup
Aplikasi ujian yang baik tidak terlihat istimewa di layar. Yang membedakannya ada di keputusan yang tidak kasatmata: soal dibekukan tepat saat siswa mulai, waktu dipegang server, urutan diacak dengan seed yang tidak pernah dikirim, dan token dikonsumsi dalam satu query.
Modul CBT di repo buku-sekolah menyimpan semua keputusan itu di kode, bukan di dokumen rancangan yang tidak pernah dibaca lagi. Kalau kamu sedang merancang aplikasi ujian, bagian yang paling layak ditiru bukan tampilannya, tapi tabel snapshot dan kolom-kolom waktu yang mungkin terasa berlebihan sampai hari pertama ujian sungguhan.







