Developing Tests and Quizzes System in LMS
Quizzes in LMS are more than questions and answers. Needed: different question types, shuffling, time limits, multiple attempts, detailed results with correct answers, cheating prevention.
Question Types
type QuestionType =
| 'single_choice' // one correct answer
| 'multiple_choice' // multiple correct
| 'true_false'
| 'short_answer' // text, checked by keywords or manually
| 'ordering' // arrange in correct order
| 'matching' // match pairs
| 'fill_blank'; // fill blank
interface Question {
id: string;
type: QuestionType;
text: string;
points: number;
explanation?: string; // shown after answer
answers: Answer[];
}
interface QuizSettings {
timeLimit?: number; // seconds, null = unlimited
maxAttempts: number; // 0 = unlimited
passingScore: number; // percent
shuffleQuestions: boolean;
shuffleAnswers: boolean;
showCorrectAnswers: 'never' | 'after_attempt' | 'after_passing';
}
Start Attempt
app.post('/api/quizzes/:quizId/attempts', authenticate, async (req, res) => {
const quiz = await db.quizzes.findById(req.params.quizId);
const enrollment = await db.enrollments.findByUserAndCourse(req.user.id, quiz.courseId);
// Check attempt limit
const previousAttempts = await db.quizAttempts.countByUserAndQuiz(
req.user.id, quiz.id
);
if (quiz.settings.maxAttempts > 0 && previousAttempts >= quiz.settings.maxAttempts) {
return res.status(429).json({ error: 'Max attempts reached' });
}
// Shuffle questions if needed
let questions = quiz.questions;
if (quiz.settings.shuffleQuestions) {
questions = shuffleArray([...questions]);
}
const attempt = await db.quizAttempts.create({
quizId: quiz.id,
userId: req.user.id,
questions: questions.map(q => ({
id: q.id,
answers: q.answers.map(a => ({ id: a.id, text: a.text })),
})),
startedAt: new Date(),
expiresAt: quiz.settings.timeLimit
? new Date(Date.now() + quiz.settings.timeLimit * 1000)
: null,
});
res.json({
attemptId: attempt.id,
questions: attempt.questions,
expiresAt: attempt.expiresAt,
});
});
Submit and Score
app.post('/api/attempts/:attemptId/submit', authenticate, async (req, res) => {
const attempt = await db.quizAttempts.findById(req.params.attemptId);
if (attempt.userId !== req.user.id) return res.status(403).end();
if (attempt.submittedAt) return res.status(409).json({ error: 'Already submitted' });
const { answers } = req.body;
const quiz = await db.quizzes.findById(attempt.quizId);
let totalPoints = 0;
let earnedPoints = 0;
const results = quiz.questions.map(question => {
totalPoints += question.points;
const userAnswer = answers[question.id];
let isCorrect = false;
let pointsEarned = 0;
switch (question.type) {
case 'single_choice':
case 'true_false':
const correctAnswer = question.answers.find(a => a.isCorrect);
isCorrect = userAnswer === correctAnswer?.id;
pointsEarned = isCorrect ? question.points : 0;
break;
case 'multiple_choice':
const correctIds = new Set(question.answers.filter(a => a.isCorrect).map(a => a.id));
const userIds = new Set(Array.isArray(userAnswer) ? userAnswer : []);
isCorrect = correctIds.size === userIds.size &&
[...correctIds].every(id => userIds.has(id));
pointsEarned = isCorrect ? question.points : 0;
break;
case 'short_answer':
const keywords = question.answers[0]?.keywords ?? [];
const matchCount = keywords.filter(kw =>
(userAnswer as string).toLowerCase().includes(kw.toLowerCase())
).length;
isCorrect = matchCount >= (question.answers[0]?.minKeywords ?? 1);
pointsEarned = isCorrect ? question.points : 0;
break;
}
earnedPoints += pointsEarned;
return { questionId: question.id, isCorrect, pointsEarned };
});
const scorePercent = Math.round((earnedPoints / totalPoints) * 100);
const passed = scorePercent >= quiz.settings.passingScore;
await db.quizAttempts.update(attempt.id, {
answers,
results,
score: scorePercent,
passed,
submittedAt: new Date(),
});
res.json({
score: scorePercent,
passed,
earnedPoints,
totalPoints,
});
});
Quiz Timer Component
function QuizTimer({ expiresAt, onExpired }) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
const diff = Math.max(0, new Date(expiresAt).getTime() - Date.now());
setRemaining(Math.floor(diff / 1000));
if (diff <= 0) {
onExpired();
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
}, [expiresAt, onExpired]);
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
return (
<div className={remaining < 60 ? 'text-red-600 font-bold' : 'text-gray-600'}>
Time remaining: {minutes}:{seconds.toString().padStart(2, '0')}
</div>
);
}
Timeframe
Basic quiz system (single choice, scoring) — 1 week. With all question types, time limits, multiple attempts, and analytics — 2–3 weeks.







