Developing a Test and Quiz System for LMS
We integrate a flexible testing system into your LMS that handles any scenario: from simple surveys to certification exams with cheating protection. Together we identify the needed question types, time settings, and automatic grading rules. The result is a ready module with API, client-side timer, and admin panel. For example, in one project for a corporate university we replaced an outdated in-house system. The number of technical failures dropped by 80%, and the average test completion time fell from 30 to 18 minutes. Students got instant feedback, teachers got detailed statistics per question. The system is designed to easily adapt to curriculum changes without rewriting code.
What Problems Do We Solve?
Problem 1: Monotonous question types. Only single choice? Students quickly adapt. We add 7+ types: from ordering to fill-in-the-blank. This boosts engagement and assessment accuracy. More about test question types.
Problem 2: Cheating and manipulation. Without protection, results lose meaning. We include: shuffling, timer, attempt limits, no backtracking, anti-cheat detection. On one project, honest completions increased by 40%.
Problem 3: Manual grading. We automate what can be automated. Short answers are checked by keywords, multiple choice gets partial credit. The teacher only moderates exceptional cases. Automatic short_answer checking is 5 times faster than manual moderation, with up to 95% accuracy when keywords are well-chosen.
Problem 4: Integration with existing LMS. Often the test module works in isolation, data not synced. We embed the system via REST API, synchronizing users, courses, and progress. This eliminates manual result transfer and prevents errors.
How We Do It
We use TypeScript on the React + Node.js stack. Database: PostgreSQL (for complex relationships) or Redis (for timers). We employ Redis caching for timers and results to speed up computations. An example of a typical system (simplified):
type QuestionType =
| 'single_choice' // one correct answer
| 'multiple_choice' // several correct
| 'true_false'
| 'short_answer' // text, checked by keywords or manually
| 'ordering' // arrange in correct order
| 'matching' // match pairs
| 'fill_blank'; // fill in the blank
interface Question {
id: string;
type: QuestionType;
text: string;
points: number;
explanation?: string; // shown after answer
answers: Answer[];
// For fill_blank
blanks?: string[];
// For matching
pairs?: Array<{ left: string; right: string }>;
}
interface QuizSettings {
timeLimit?: number; // seconds, null = unlimited
maxAttempts: number; // 0 = unlimited
passingScore: number; // percentage
shuffleQuestions: boolean;
shuffleAnswers: boolean;
showCorrectAnswers: 'never' | 'after_attempt' | 'after_passing';
allowReview: boolean; // can go back to previous questions
}
Storage of questions and answers in the database is designed with normalization in mind: table questions contains fields type, text, points; table answers — id, text, isCorrect, questionId; for ordering and matching — auxiliary tables with order and pairs.
How We Implement Cheating Protection
Starting an attempt is done server-side: we shuffle questions and answers, check the attempt limit. Only IDs and text are sent to the client, never correct answers. When submitting, the timer is validated on the server. Server-side validation prevents client-side time manipulation.
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
let questions = quiz.questions;
if (quiz.settings.shuffleQuestions) {
questions = shuffleArray([...questions]);
}
if (quiz.settings.shuffleAnswers) {
questions = questions.map(q => ({
...q,
answers: q.type !== 'ordering' ? shuffleArray([...q.answers]) : q.answers,
}));
}
const attempt = await db.quizAttempts.create({
quizId: quiz.id,
userId: req.user.id,
enrollmentId: enrollment.id,
questions: questions.map(q => ({
id: q.id,
// Do NOT send isCorrect to the client
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,
});
});
Why Configuring the Timer Matters
The timer is not just about time limits. The client-side React component QuizTimer shows a countdown, warns one minute before the end, and auto-submits the test. On the server, we verify that the attempt didn't exceed the limit — this guarantees fairness. We use useState and useEffect from React documentation.
function QuizTimer({ expiresAt, onExpired }) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
const update = () => {
const diff = Math.max(0, new Date(expiresAt).getTime() - Date.now());
setRemaining(Math.floor(diff / 1000));
if (diff <= 0) onExpired();
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [expiresAt]);
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
const isUrgent = remaining < 60;
return (
<div className={`font-mono text-lg font-bold ${isUrgent ? 'text-red-600 animate-pulse' : 'text-gray-700'}`}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
);
}
In addition to the client-side timer, on each submit request the server checks that time has not expired. If an attempt is submitted after the deadline, we return a 408 error. This prevents cheating by manipulating the client time.
How Answers Are Graded
After submission, the server calculates scores, accounting for partial credit on multiple choice and keywords for short answers. Comparison of automatic vs. manual grading:
| Grading method | Time for 100 answers | Accuracy |
|---|---|---|
| Automatic | 0.5 s | 95% |
| Manual | 2 hours | 98% |
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' });
// Check timer
if (attempt.expiresAt && new Date() > attempt.expiresAt) {
return res.status(408).json({ error: 'Time expired' });
}
const { answers } = req.body; // { questionId: answerId | answerId[] | string }
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));
// Partial scoring for multiple choice
const correctSelected = [...userIds].filter(id => correctIds.has(id)).length;
const wrongSelected = [...userIds].filter(id => !correctIds.has(id)).length;
pointsEarned = Math.max(0,
(correctSelected / correctIds.size - wrongSelected / correctIds.size) * question.points
);
break;
case 'ordering':
const correctOrder = question.answers.map(a => a.id);
isCorrect = JSON.stringify(userAnswer) === JSON.stringify(correctOrder);
pointsEarned = isCorrect ? question.points : 0;
break;
case 'short_answer':
// Auto-check by keywords, manual check separate
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, correctAnswer: question.answers.filter(a => a.isCorrect) };
});
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(),
timeSpent: Math.round((Date.now() - new Date(attempt.startedAt).getTime()) / 1000),
});
if (passed) {
await db.lessonProgress.markCompleted(req.user.id, quiz.lessonId);
}
const showAnswers = quiz.settings.showCorrectAnswers === 'after_attempt' ||
(quiz.settings.showCorrectAnswers === 'after_passing' && passed);
res.json({
score: scorePercent,
passed,
earnedPoints,
totalPoints,
results: showAnswers ? results : results.map(r => ({ questionId: r.questionId, isCorrect: r.isCorrect })),
});
});
Work Process
- Analysis — study your LMS, list of question types, protection requirements.
- Design — database schema, API, question rendering, timer logic.
- Implementation — write code, test on real data.
- Testing — load testing, protection checks, usability.
- Deployment and support — deploy, hand over documentation, train administrators.
Timeline and What's Included
| Component | Timeline |
|---|---|
| 4–5 question types + timer + attempts | 1–1.5 weeks |
| Additional types (>5) | +2–3 days |
| Integration with existing LMS | from 3 days |
| Manual grading of essay answers | from 2 days |
Included: API, client-side timer component, admin panel for test management, database migrations, documentation, training, post-launch support.
Get a consultation for your project — we'll advise which question types suit you. Order development of a test system: from a simple survey to a certification exam. Our team has 5+ years of experience and over 50 test system implementations in various LMSs. We guarantee stability and data security.







