Build a JavaScript quiz: separate the logic and test it
A quiz teaches arrays, functions and testing. The key lesson: keep logic apart from the screen so the same code works in a terminal or a web page.

A quiz looks like a beginner toy, but it hides a lesson that matters in every real project: keep the logic separate from the interface. When the rules (shuffle, check, score) live in plain functions, the same code can power a terminal quiz today and a web page tomorrow, and you can test it without clicking anything. The code and outputs below were run with Node.js 22 before publishing.
What we are building
- A list of questions with choices and the index of the correct answer.
- A shuffle so questions come in random order.
- Scoring that returns the score, a percentage and a message.
- A small terminal interface that asks the questions and rejects invalid input.
- Tests for the logic.
Step 1: the logic (quiz.mjs)
export const questions = [
{ text: "Which keyword declares a variable that cannot be reassigned?", choices: ["var", "let", "const"], answer: 2 },
{ text: "What does Array.prototype.map return?", choices: ["A new array", "The same array", "undefined"], answer: 0 },
{ text: "What is the result of typeof null?", choices: ["'null'", "'object'", "'undefined'"], answer: 1 },
];
// Fisher-Yates shuffle. `random` is injectable so tests can control it.
export function shuffle(items, random = Math.random) {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export function isCorrect(question, choiceIndex) {
return choiceIndex === question.answer;
}
export function grade(questions, picks) {
const score = questions.filter((q, i) => isCorrect(q, picks[i])).length;
const percent = questions.length ? Math.round((score / questions.length) * 100) : 0;
const message = percent === 100 ? "Perfect!" : percent >= 60 ? "Good job." : "Keep practising.";
return { score, total: questions.length, percent, message };
}
Nothing here prints or reads input. Every function takes data and returns data. That is what makes it easy to test.
Step 2: the terminal interface (cli.mjs)
import { createInterface } from "node:readline";
import { questions, shuffle, grade } from "./quiz.mjs";
const lines = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
async function ask(prompt) {
process.stdout.write(prompt);
const { value, done } = await lines.next();
if (done) throw new Error("Input ended before the quiz finished");
return value;
}
const asked = shuffle(questions);
const picks = [];
for (const [n, q] of asked.entries()) {
console.log(`\nQ${n + 1}. ${q.text}`);
q.choices.forEach((choice, i) => console.log(` ${i + 1}) ${choice}`));
let pick = NaN;
while (!(pick >= 0 && pick < q.choices.length)) {
pick = Number(await ask("Your answer: ")) - 1;
}
picks.push(pick);
}
const { score, total, percent, message } = grade(asked, picks);
console.log(`\nScore: ${score}/${total} (${percent}%). ${message}`);
process.exit(0);
Try it
$ node cli.mjs
Q1. What does Array.prototype.map return?
1) A new array
2) The same array
3) undefined
Your answer: 1
Q2. What is the result of typeof null?
1) 'null'
2) 'object'
3) 'undefined'
Your answer: 9
Your answer: 2
Q3. Which keyword declares a variable that cannot be reassigned?
1) var
2) let
3) const
Your answer: 3
Score: 3/3 (100%). Perfect!
Question order is random on each run. Entering 9 is rejected and the question is asked again, because the loop continues until the pick is a valid choice.
How it works
Data as objects. Each question is { text, choices, answer }. To add a question you add one object. No code changes.
Fisher-Yates shuffle. Walk from the end of the array and swap each item with a random earlier position. It gives every order the same chance. A common shortcut, array.sort(() => Math.random() - 0.5), is biased and should not be used for fair shuffling.
Copy before changing. [...items] copies the array, so shuffle does not change the original. Functions that surprise callers by changing their input cause hard bugs.
Injectable randomness. shuffle(items, random = Math.random) lets a test pass its own function, so a "random" result becomes predictable and testable.
Scoring. filter counts correct answers. The percentage guards against dividing by zero for an empty quiz.
Input checking. Number("abc") is NaN, and NaN >= 0 is false, so the while loop keeps asking. Never trust what a user types.
Reading lines. The readline async iterator reads one line at a time and also works when answers are piped in, which is handy for automatic checks.
Step 3: tests (quiz.test.mjs)
import test from "node:test";
import assert from "node:assert/strict";
import { questions, shuffle, grade, isCorrect } from "./quiz.mjs";
test("isCorrect compares the chosen index with the answer", () => {
assert.equal(isCorrect(questions[0], 2), true);
assert.equal(isCorrect(questions[0], 1), false);
});
test("grade counts correct picks and builds a message", () => {
assert.deepEqual(grade(questions, [2, 0, 1]), { score: 3, total: 3, percent: 100, message: "Perfect!" });
assert.equal(grade(questions, [2, 0, 0]).percent, 67);
assert.equal(grade(questions, [0, 1, 0]).message, "Keep practising.");
});
test("grade handles an empty quiz", () => {
assert.equal(grade([], []).percent, 0);
});
test("shuffle keeps every item and does not change the original", () => {
const original = [1, 2, 3, 4, 5];
const result = shuffle(original);
assert.deepEqual([...result].sort(), original);
assert.deepEqual(original, [1, 2, 3, 4, 5]);
});
test("shuffle is predictable with a fixed random function", () => {
assert.deepEqual(shuffle([1, 2, 3], () => 0), [2, 3, 1]);
});
$ node --test
✔ isCorrect compares the chosen index with the answer
✔ grade counts correct picks and builds a message
✔ grade handles an empty quiz
✔ shuffle keeps every item and does not change the original
✔ shuffle is predictable with a fixed random function
# tests 5
# pass 5
# fail 0
Reuse the logic in a web page
Because quiz.mjs is a plain ES module, a web page can import it directly: import { grade, shuffle } from "./quiz.mjs". The page only has to draw the questions and collect clicks. The scoring code does not change.
Known limits
- Answers are in the source code, so this is not suitable for a real exam. Anyone who can read the code can read the answers.
- Questions are fixed in the file. Load them from JSON for a larger quiz.
- Only single-choice questions, with no timer and no saved high scores.
Challenges
- Move the questions to
questions.jsonand load them withfs.readFile. - Add a 15-second timer per question.
- Show which questions were wrong, with the correct answer.
- Save the best score to a file.
- Build the web version with HTML and a few lines of JavaScript that import
quiz.mjs.
Takeaway: put rules in small pure functions, keep input and output at the edges, and test the rules. That one habit makes projects easier to change, reuse and trust.
Build-along projects series, Part 1 (small tested projects): 5 of 5. Previous: Python and SQLite: build a to-do app with CRUD (tested).


