Cara Mendesain Aplikasi Ujian yang Tidak Bisa Ditebak Siswa

Abd. Asis
Cara Mendesain Aplikasi Ujian yang Tidak Bisa Ditebak Siswa

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:

LapisIsiSifat
Bank soalkumpulan soal per mata pelajaran dan tingkathidup, terus diperbarui
Soalpertanyaan, tipe, tingkat kesulitan, opsibisa diimpor dari Excel
Ujiankonfigurasi: durasi, jumlah soal, aturan acak, KKMdibuat 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.

PHPPHP
// SnapshotBuilder: menyalin soal ke snapshot attempt, sekaligus mengacak
$orderedQuestions = ($exam?->shuffle_questions && $seed)
    ? collect($this->shuffleSeeder->shuffle($questions->all(), $seed))
    : $questions;

Manfaatnya tiga:

  1. Nilai historis tetap sah. Guru boleh memperbaiki soal besok, nilai siswa hari ini tetap mengacu pada teks yang benar-benar dia baca.
  2. Soal bisa dihapus tanpa merusak arsip. Snapshot menyimpan salinan, jadi penghapusan di bank tidak menghapus bukti ujian.
  3. 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:

PHPPHP
// 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 srand atau mt_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:

PHPPHP
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:

PHPPHP
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 soalPenilai
Pilihan gandaMultipleChoiceGrader
Benar salahTrueFalseGrader
MenjodohkanMatchingGrader
Jawaban singkatShortAnswerGrader
Esaimasuk 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:

PHPPHP
$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:

PHPPHP
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:

KebutuhanSolusi di modul
Siswa tidak salah ruangcbt_rooms, classroom_room_slots, exam_participant_numbers
Pengawas per ruangexam_proctors
Jadwal per sesiexam_time_slots
Jejak aktivitas siswasession_activity_logs, violations_count
Laporan hasilexam_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:

  1. Pisahkan bank soal, soal, dan ujian.
  2. Bekukan soal ke snapshot saat attempt dimulai, termasuk poin per soal.
  3. Simpan seed pengacakan per attempt, dan jangan pernah kirim ke client.
  4. Simpan expires_at di server, dan tegakkan dengan buffer beberapa detik.
  5. Catat auto_submitted untuk membedakan kumpul sendiri dan kehabisan waktu.
  6. Simpan client_changed_at dan server_saved_at pada setiap jawaban.
  7. Beri unique index pada pasangan attempt dan soal, supaya kiriman berulang jadi update.
  8. Konsumsi token dengan satu query atomik, bukan baca-lalu-tulis.
  9. Pisahkan penilai per tipe soal, dan taruh esai di antrean manual.
  10. Hitung nilai maksimum dari snapshot, bukan dari konfigurasi terbaru.
  11. Simpan statistik per butir soal untuk evaluasi bank soal.
  12. 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.

Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.