Realitas Rekayasa Sistem Ujian Online Berskala Besar
Dalam pelaksanaan ujian akademik berisiko tinggi (high-stakes exams), engine Computer Based Testing (CBT) berhadapan dengan kondisi operasional ekstrem yang jarang ditemui pada aplikasi bisnis umum:
- Manipulasi Jam Klien: Peserta ujian dapat dengan mudah mengubah jam sistem operasi laptop, membekukan event loop JavaScript, atau mengotak-atik local storage untuk memperpanjang waktu ujian secara curang.
- Volatilitas Jaringan Lab Komputer: Jika router Wi-Fi sekolah mengalami packet loss, mengirimkan seluruh 50 jawaban sekaligus dalam satu request HTTP POST di detik-detik akhir ujian adalah resep bencana kehilangan data massal.
- Telemetri Pengawasan Berskala Ratusan Peserta: Guru dan panitia pengawas ujian membutuhkan visibilitas seketika atas aktivitas siswa (nomor soal aktif, sisa waktu, dan counter pelanggaran) tanpa membuat database kewalahan akibat polling HTTP setiap 2 detik.
Melanjutkan fondasi Clean Architecture yang telah kita bangun pada Part 2, pada proyek mfarim/ruby-rails-elearning-react, kami merancang engine CBT menggunakan Use Cases berprinsip single responsibility, perhitungan waktu mutlak server, dan siaran telemetri real-time via Action Cable.
1. Triad Use Cases CBT Berbasis Clean Architecture
Daripada membiarkan controller mengeksekusi operasi database secara langsung atau membebani model Active Record dengan callback yang berbelit-belit, seluruh domain ujian diorkestrasi melalui Use Cases terisolasi yang mengembalikan objek UseCaseResult standar.
A. Pengatur Waktu Otoritatif Server (Exams::StartExamAttempt)
Ketika siswa memulai ujian melalui endpoint POST /api/v1/exams/:id/start, kelas Exams::StartExamAttempt menginisialisasi sesi dan menghitung sisa durasi murni berdasarkan jam server:
RUBY
# backend/app/use_cases/exams/start_exam_attempt.rb
module Exams
class StartExamAttempt
def initialize(
exam_repo: ExaminationRepository.new,
attempt_repo: ExamAttemptRepository.new
)
@exam_repo = exam_repo
@attempt_repo = attempt_repo
end
def call(exam_id:, student:)
return UseCaseResult.failure("Data siswa tidak ditemukan", status: :forbidden) unless student
exam = @exam_repo.find(exam_id)
attempt = @attempt_repo.find_or_initialize_for(exam, student)
# 1. Inisialisasi attempt jika baru atau diizinkan mengulang
if attempt.new_record? || (exam.allow_retry && attempt.status == 'completed')
attempt.status = 'in_progress'
attempt.started_at = Time.current
attempt.remaining_seconds = exam.duration_minutes * 60
attempt.violations_count = 0
attempt.save!
end
# 2. Pengacakan nomor soal jika diaktifkan
questions = exam.questions.order(:id).to_a
questions = questions.shuffle if exam.shuffle_questions
existing_answers = ExamAnswer.where(exam_attempt: attempt).index_by(&:question_id)
questions_payload = questions.map do |q|
options = q.options || []
options = options.shuffle if exam.shuffle_options
{
id: q.id,
examinationId: q.examination_id,
questionText: q.question_text,
questionType: q.question_type,
options: options,
points: q.points,
difficulty: q.difficulty,
studentAnswer: existing_answers[q.id]&.student_answer,
imagePath: q.image_path,
audioPath: q.audio_path
}
end
# 3. Kalkulasi sisa waktu mutlak server
elapsed = (Time.current - attempt.started_at).to_i
remaining = [exam.duration_minutes * 60 - elapsed, 0].max
response_data = {
attemptId: attempt.id,
examId: exam.id,
examTitle: exam.title,
durationMinutes: exam.duration_minutes,
remainingSeconds: remaining,
startedAt: attempt.started_at.iso8601,
totalQuestions: questions.size,
questions: questions_payload
}
UseCaseResult.success(response_data)
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
Mengapa Pola Ini Mencegah Manipulasi Jam Klien:
Bahkan jika siswa mematikan sementara koneksi internet atau memundurkan jam sistem operasi mereka, saat halaman dibuka kembali, backend melakukan kalkulasi ulang secara mutlak: $$\text{Sisa Detik} = \max\left(0, (\text{Durasi} \times 60) - (\text{Time.current} - \text{attempt.started_at})\right)$$ Jika sisa waktu bernilai 0, setiap pengiriman jawaban berikutnya akan langsung ditolak oleh backend.
B. Autosave Jawaban Instan (Exams::SaveExamAnswer)
Menyimpan jawaban ujian tidak boleh menjadi operasi massal yang berisiko tinggi. Di Exams::SaveExamAnswer, setiap kali siswa memilih salah satu opsi, jawaban langsung disimpan secara atomik:
RUBY
# backend/app/use_cases/exams/save_exam_answer.rb
module Exams
class SaveExamAnswer
def initialize(attempt_repo: ExamAttemptRepository.new)
@attempt_repo = attempt_repo
end
def call(attempt_id:, question_id:, answer:)
attempt = @attempt_repo.find(attempt_id)
if attempt.status != 'in_progress'
return UseCaseResult.failure("Sesi ujian sedang tidak aktif", status: :unprocessable_entity)
end
# Operasi Upsert atomik jawaban siswa
ans = ExamAnswer.find_or_initialize_by(
exam_attempt: attempt,
question_id: question_id
)
ans.student_answer = answer
ans.save!
# Siarkan update progres seketika via Action Cable
ActionCable.server.broadcast(
"exam_monitor_#{attempt.examination_id}",
{
eventType: "ANSWER_SAVED",
studentId: attempt.student_id,
answeredCount: attempt.exam_answers.count
}
)
UseCaseResult.success({ saved: true, questionId: question_id })
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
C. Penilaian Otomatis & Penandaan Esai (Exams::SubmitExamAttempt)
Ketika ujian selesai atau batas waktu berakhir, Exams::SubmitExamAttempt menghitung nilai, mengevaluasi kelulusan berbasis KKM, serta menandai soal esai yang butuh penilaian manual guru:
RUBY
# backend/app/use_cases/exams/submit_exam_attempt.rb
module Exams
class SubmitExamAttempt
def initialize(attempt_repo: ExamAttemptRepository.new)
@attempt_repo = attempt_repo
end
def call(attempt_id:)
attempt = @attempt_repo.find(attempt_id)
exam = attempt.examination
questions = exam.questions.index_by(&:id)
answers = attempt.exam_answers.includes(:question)
total_points = questions.values.sum(&:points)
earned_points = 0.0
has_essay = false
# Penilaian otomatis untuk soal objektif
answers.each do |ans|
q = ans.question
next unless q
if q.question_type == 'multiple_choice' || q.question_type == 'true_false'
is_correct = ans.student_answer.to_s.strip.downcase == q.correct_answer.to_s.strip.downcase
ans.update!(
is_correct: is_correct,
points_awarded: is_correct ? q.points : 0.0,
graded_at: Time.current
)
earned_points += q.points if is_correct
elsif q.question_type == 'essay'
has_essay = true
end
end
score_pct = total_points > 0 ? (earned_points / total_points * 100.0).round(2) : 0.0
passed = score_pct >= exam.passing_score
# Jika ada soal esai, status attempt beralih ke 'needs_grading'
attempt.update!(
score: score_pct,
passed: passed,
finished_at: Time.current,
status: has_essay ? 'needs_grading' : 'completed',
remaining_seconds: 0
)
payload = {
score: attempt.score,
passed: attempt.passed,
status: attempt.status,
totalQuestions: questions.size,
answeredCount: answers.size
}
UseCaseResult.success(payload)
rescue ActiveRecord::RecordNotFound => e
UseCaseResult.failure(e.message, status: :not_found)
end
end
end
2. Deteksi Kecurangan & Penguncian Otomatis
Pada backend/app/controllers/api/v1/exam_runner_controller.rb, server mencatat setiap indikasi pelanggaran yang dilaporkan dari browser siswa:
RUBY
# backend/app/controllers/api/v1/exam_runner_controller.rb
def violation
require_student!
return if performed?
attempt = exam_attempt_repository.find(params[:attempt_id])
attempt.increment!(:violations_count)
# Diskualifikasi otomatis jika pelanggaran mencapai ambang batas 5x
force_submitted = false
if attempt.violations_count >= 5
attempt.update!(status: 'force_finished', finished_at: Time.current)
force_submitted = true
end
# Kirim peringatan ke pengawas via Action Cable
ActionCable.server.broadcast(
"exam_monitor_#{attempt.examination_id}",
{
eventType: "VIOLATION_LOGGED",
studentId: attempt.student_id,
violationsCount: attempt.violations_count,
forceSubmitted: force_submitted
}
)
json_response(
data: { violations: attempt.violations_count, forceSubmitted: force_submitted },
message: "Pelanggaran tercatat"
)
end
Event Hook Klien Ujian (ExamRunner.tsx)
Di frontend React, listener memantau jika siswa beralih jendela atau keluar dari mode layar penuh:
TYPESCRIPT
// frontend/src/pages/exams/ExamRunner.tsx
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
reportViolation("TAB_SWITCH_DETECTED");
}
};
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
reportViolation("FULLSCREEN_EXITED");
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
3. Telemetri Real-Time dengan Action Cable & Redis
Untuk mendukung pengawasan langsung tanpa membebani database, Rails 8 mengintegrasikan Action Cable secara native. Pada backend/config/cable.yml:
YAML
# backend/config/cable.yml
development:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: elearning_production
Di production, Action Cable menggunakan Redis Pub/Sub sebagai backplane. Ketika worker Puma menyimpan jawaban atau mencatat pelanggaran, event tersebut dipublikasikan ke Redis dan disiarkan seketika ke seluruh pengawas yang membuka ExamMonitor.tsx:
TYPESCRIPT
// frontend/src/pages/exams/ExamMonitor.tsx
useEffect(() => {
const cable = createConsumer(`wss://${window.location.host}/cable`);
const subscription = cable.subscriptions.create(
{ channel: "ExamChannel", exam_id: examId },
{
received(data) {
if (data.eventType === "VIOLATION_LOGGED") {
flashCandidateViolation(data.studentId, data.violationsCount);
} else if (data.eventType === "ANSWER_SAVED") {
updateProgressBar(data.studentId, data.answeredCount);
}
}
}
);
return () => subscription.unsubscribe();
}, [examId]);
Ringkasan Rekomendasi Arsitektural
- Pisahkan alur kerja domain dari lapisan HTTP: Dengan merancang
StartExamAttempt,SaveExamAnswer, danSubmitExamAttemptsebagai Use Cases mandiri, logika bisnis ujian dapat diuji dalam hitungan milidetik secara terisolasi menggunakan unit test RSpec. - Jangan pernah mengkalkulasi batas waktu di sisi klien: Simpan timestamp UTC
started_atyang mutlak pada database dan hitung sisa durasi secara dinamis di server pada setiap interaksi. - Manfaatkan Action Cable dan Redis untuk efisiensi pengawasan: Mengeliminasi polling HTTP setiap 2 detik dari ratusan siswa menghemat konsumsi koneksi database hingga lebih dari 90%.
Pada Part 4, kita akan membedah pengolahan data massal dan penguatan DevSecOps: Pipeline Streaming Berkas Spreadsheet dengan Gem Roo, 1-Click Admin Impersonation, serta Audit Keamanan Otomatis Menggunakan Brakeman & Bundler-Audit.

