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 terminal running a JavaScript quiz and showing the final score

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

  1. Move the questions to questions.json and load them with fs.readFile.
  2. Add a 15-second timer per question.
  3. Show which questions were wrong, with the correct answer.
  4. Save the best score to a file.
  5. 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).

Keep reading