DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building a Quiz CLI with TypeScript — enum, Tuple Types, and Jest Mocks

Introduction

This is my fifth article as a Java engineer learning TypeScript from scratch.

In my previous article, I built a Household Budget CLI and learned about class, generics <T>, interface extends, and array methods like reduce, some, and find. This time, I built a Quiz CLI and focused on:

  • Managing choice IDs safely with enum
  • Constraining array length at the type level with tuple types
  • Replacing callback hell with async/await
  • Swapping out the fs module entirely with jest.mock()
  • Understanding when to use jest.spyOn vs jest.mock()

Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.


My Learning Style (AI Transparency)

I use Claude Pro (design discussions and Q&A) and Cursor Pro (coding support) as learning companions.

My rules:

  • I write all the code myself — I never ask AI to write code for me
  • AI helps with hints, spec clarification, and bug spotting
  • I make sure I understand why something works before moving on

In this article, I clearly separate "what I implemented myself" from "what I asked AI for."


What I Built

A CLI tool to create, edit, delete, and randomly play quiz questions stored in a JSON file.

==============================
1. Add a quiz
2. Edit a quiz
3. Delete a quiz
4. Play quizzes (3 random questions)
5. Show all quizzes
6. Exit
> 1
Enter the question: What keyword defines a type alias in TypeScript?
Enter option 1: type
Enter option 2: interface
Enter the correct answer (1 or 2): 1
Quiz added.

> 4
--- Question 1 ---
What keyword defines a type alias in TypeScript?
1: type
2: interface
Your answer (1 or 2): 1
✅ Correct!

Result: 1 / 1 correct
==============================
Enter fullscreen mode Exit fullscreen mode

Quiz data is saved to a JSON file and persists between sessions.

📦 Repository: https://github.com/uya0526-design/simple_quiz


Project Structure

simple_quiz/
├── src/
│   ├── index.ts             # Entry point / CLI menu
│   ├── quiz.ts              # Business logic (QuizManager class)
│   ├── types.ts             # Type definitions
│   └── __tests__/
│       └── quiz.test.ts     # Unit tests (10 cases)
├── quizzes.json             # Persistent quiz data
├── jest.config.js
├── tsconfig.json
└── package.json
Enter fullscreen mode Exit fullscreen mode

Same three-layer structure as the previous projects: types.ts (types) / quiz.ts (logic) / index.ts (UI).
New this time: JSON file persistence.


Tech Stack

  • TypeScript
  • Node.js (readline and fs modules)
  • Jest + ts-jest (unit testing)

What I Implemented Myself

types.ts — Type Definitions

Two key ideas drove the type design this time: enum and tuple types.

enum to name choice IDs

The quiz is always two choices. My first instinct was to use plain number, but 1 and 2 on their own carry no meaning. enum lets me give those numbers names:

export enum OptionId {
  One = 1,
  Two = 2,
}
Enter fullscreen mode Exit fullscreen mode

OptionId.One is immediately readable — you know it means "the first option." Same concept as a Java enum paired with an integer value.

Tuple types to fix array length

I first typed the options array as option[]. The problem: that accepts zero items, three items, or any count — but a quiz always has exactly two choices. A tuple locks that down:

export type option = {
  id: OptionId;
  text: string;
};

export interface Quiz {
  id: number;
  question: string;
  options: [option, option]; // exactly 2 — enforced at compile time
  answer: OptionId;
}
Enter fullscreen mode Exit fullscreen mode

With [option, option], TypeScript rejects anything that isn't exactly two elements. option[] couldn't prevent that — tuple types can.


quiz.ts — The QuizManager Class

I decided to separate logic into a QuizManager class and keep index.ts as pure I/O. The previous household budget project taught me that this separation makes testing much easier — same principle as a Java Service layer vs. Controller layer.

export class QuizManager {
  private quizzes: Quiz[] = [];

  constructor() {}

  loadQuizzes(): void {
    if (!fs.existsSync(FILE_PATH)) {
      fs.writeFileSync(FILE_PATH, JSON.stringify([], null, 2));
    }
    const data = fs.readFileSync(FILE_PATH, 'utf-8');
    this.quizzes = JSON.parse(data);
  }

  saveQuizzes(): void {
    fs.writeFileSync(FILE_PATH, JSON.stringify(this.quizzes, null, 2));
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

ID generation with reduce

Rather than keeping a nextId field in the stored data (which would pollute the JSON), I compute it from existing records:

private getNextQuizId(): number {
  if (this.quizzes.length === 0) return 1;
  return this.quizzes.reduce((maxId, quiz) => Math.max(maxId, quiz.id), 0) + 1;
}
Enter fullscreen mode Exit fullscreen mode

Start at 0, walk through all IDs, take the max, add one. If a quiz is deleted and a new one is added, there are no ID collisions.

isQuizExists with some

isQuizExists(id: number): boolean {
  return this.quizzes.some((quiz) => quiz.id === id);
}
Enter fullscreen mode Exit fullscreen mode

Guard method used before editing or deleting. some returns true if at least one element matches — Java's Stream.anyMatch(). I'd used this pattern in the budget project too, so it came naturally.


index.ts — CLI Menu

async/await to escape callback nesting

The budget project's readline callbacks were readable, but I could already see how deeper nesting would make them hard to follow. This time I wrapped readline in a Promise from the start:

function askQuestion(prompt: string): Promise<string> {
  return new Promise((resolve) => {
    rl.question(prompt, (answer) => {
      resolve(answer);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Then the main flow reads top-to-bottom:

// Callback style (nesting grows with each input)
rl.question('Question: ', (q) => {
    rl.question('Option 1: ', (o1) => {
        rl.question('Option 2: ', (o2) => {
            // ...
        });
    });
});

// async/await (reads like sequential code)
const question = await askQuestion('Enter the question: ');
const option1  = await askQuestion('Enter option 1: ');
const option2  = await askQuestion('Enter option 2: ');
Enter fullscreen mode Exit fullscreen mode

It feels similar to waiting on a Java Future with .get(), but without blocking a thread.

Random question selection

const randomQuizzes = quizManager.getQuizzes()
  .sort(() => Math.random() - 0.5)
  .slice(0, 3);
Enter fullscreen mode Exit fullscreen mode

sort(() => Math.random() - 0.5) is a standard shuffle idiom — the comparator returns a random positive or negative value, randomizing the sort order. .slice(0, 3) then takes the first three.


quiz.test.ts — Unit Tests with Jest

The toughest part of testing this project was mocking the fs module.

jest.spyOn vs jest.mock()

I started with jest.spyOn(fs, 'readFileSync'). Immediate error:

Cannot redefine property: readFileSync
Enter fullscreen mode Exit fullscreen mode

AI explained why: Node's fs module methods have configurable: false in their property descriptors. jest.spyOn works by redefining the property — which is forbidden when configurable is false.

The fix is to replace the entire module instead of patching individual methods:

// ❌ fs methods are configurable: false — spyOn can't redefine them
jest.spyOn(fs, 'readFileSync').mockReturnValue('...');

// ✅ jest.mock() replaces the whole module — no configurable restriction
jest.mock('fs');
const mockedFs = jest.mocked(fs);
Enter fullscreen mode Exit fullscreen mode

Comparison:

jest.spyOn jest.mock()
Scope One method Entire module
Requirement configurable: true No restriction
Reset restoreAllMocks() resetAllMocks()
Java equivalent Mockito.spy(realObject) Mockito.mock(ClassName.class)

Note: console.error does work with jest.spyOn because console methods are configurable: true. Whether spyOn is usable depends entirely on how the object's properties are defined — not on which module it is.

The "accidentally passing test" trap

While writing the loadQuizzes test, I used JSON.stringify([]) in my assertion. It passed. AI flagged it:

// ❌ Accidentally passes — empty arrays produce "[]" either way
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
  expect.stringContaining('quizzes.json'),
  JSON.stringify([])
);

// ✅ Matches the actual call exactly
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
  expect.stringContaining('quizzes.json'),
  JSON.stringify([], null, 2)
);
Enter fullscreen mode Exit fullscreen mode

JSON.stringify([]) and JSON.stringify([], null, 2) both return "[]" for an empty array. Once data is present, the second form adds newlines and indentation — and the test would fail. "Test passes" does not mean "test is correct."

expect.stringContaining() for cross-platform paths

File paths look different on Windows (\) vs Mac/Linux (/). Rather than hardcoding a full path, I match on the filename only:

expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
  expect.stringContaining('quizzes.json'),
  // ...
);
Enter fullscreen mode Exit fullscreen mode

Test results:

 PASS  src/__tests__/quiz.test.ts
  loadQuizzes
    ✓ creates a new file if none exists
    ✓ loads data from an existing file
  saveQuizzes
    ✓ can save quizzes
  addQuiz
    ✓ can add a quiz
    ✓ second quiz gets ID 2
  editQuiz
    ✓ can edit a quiz
  deleteQuiz
    ✓ can delete a quiz
  isQuizExists
    ✓ returns true when the quiz exists
    ✓ returns false when the quiz does not exist
  getNextQuizId (tested indirectly via addQuiz)
    ✓ no ID collision after delete then add

Tests: 10 passed, 10 total
Enter fullscreen mode Exit fullscreen mode

What I Asked AI For

Topic What AI helped with
Spec clarification Identified unclear and missing requirements up front
async/await explanation Compared it to callback style and to Java's Future
Tuple type hint Suggested changing option[] to [option, option]
Constructor issue Caught that private constructor prevents external instantiation
editQuiz bug Pointed out map(q => q.id === id ? q : q) does nothing
Jest error fix Explained Cannot redefine property and suggested jest.mock()
console.error mock Showed how to use jest.spyOn(console, 'error')
Path comparison Suggested expect.stringContaining()
README corrections Fixed title and tech stack description

Where I Got Stuck

1. private constructor prevented instantiation

Situation: Wrote private constructor() on QuizManager. Then new QuizManager() threw an error.

Root cause: private constructor is intentional — it's the pattern for preventing external instantiation (e.g., singletons). A regular class should just use constructor() (public by default).

// ❌ Can't be instantiated from outside the class
export class QuizManager {
  private constructor() {}
}

// ✅ Public constructor — the normal case
export class QuizManager {
  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode

Same rule applies in Java. A slip of habit.


2. editQuiz had a no-op map

Situation: After calling editQuiz, nothing changed. AI asked me to re-read the map callback.

Root cause: Both branches of the ternary returned the same value:

// ❌ Both branches return `quiz` — nothing is updated
this.quizzes = this.quizzes.map((quiz) =>
  quiz.id === id ? quiz : quiz
);

// ✅ Return the updated object when IDs match
this.quizzes = this.quizzes.map((quiz) =>
  quiz.id === id ? updatedQuiz : quiz
);
Enter fullscreen mode Exit fullscreen mode

The intent was clear — the execution was wrong. The kind of bug that's invisible until you slow down and re-read the line.


3. let inside a switch case caused a scope error

Situation: let score = 0 inside case '6' caused a TypeScript error.

Root cause: switch cases share a single scope. let and const declarations in one case can conflict with those in others.

// ❌ All cases share one scope — let can conflict across cases
case '6':
  let score = 0;
  break;

// ✅ Wrap in braces to create a block scope
case '6': {
  let score = 0;
  break;
}
Enter fullscreen mode Exit fullscreen mode

Wrapping in {} gives each case its own block scope. Simple fix once you know the cause.


4. console.error needed spyOn, not jest.mock()

Unlike fs, console.error works fine with jest.spyOn:

jest.spyOn(console, 'error').mockImplementation(() => {});
Enter fullscreen mode Exit fullscreen mode

Because console properties are configurable: true. The lesson isn't "use jest.mock() for everything" — it's "know your target's property descriptor before choosing your approach."


What I Learned

async/await

  • Wrapping readline in new Promise((resolve) => { ... }) makes it await-able
  • async/await turns nested callbacks into sequential, top-to-bottom code
  • Feels like Java's CompletableFuture.get(), but non-blocking

TypeScript Types

Topic Key Takeaway
enum Gives numeric values meaningful names. OptionId.One beats a raw 1
Tuple [A, B] Enforces exact array length and element types at compile time
Missing export Any type or class used across files needs export — easy to forget

Jest Mocking

Topic Key Takeaway
jest.spyOn() Works when configurable: true — patches a single method
jest.mock() Replaces the entire module — no configurable restriction
jest.mocked(fn) Utility that tells TypeScript a function is already mocked
jest.resetAllMocks() Clears mock implementations and call history

Test Quality

  • A test that passes is not necessarily a correct test
  • JSON.stringify([]) and JSON.stringify([], null, 2) look different but both return "[]" for empty arrays
  • expect.stringContaining() handles OS path differences without hardcoding separators
  • Write tests for error paths (catch blocks), not just happy paths

Design

  • Separating logic (quiz.ts) from I/O (index.ts) makes the logic independently testable
  • Code that touches the filesystem during tests should be mocked out
  • Same principle as Java's Service / Controller separation

Reflection

async/await built on last project's frustration: The budget project's readline callbacks were fine, but I could see the wall coming. Starting this project with async/await from day one felt like applying a lesson before being forced to — that's a good sign.

Tuple types clicked because I had a concrete need: "This quiz must always have exactly two options" is a real constraint. Expressing that constraint as [option, option] instead of option[] felt like TypeScript doing what it's supposed to do — enforcing things so I don't have to remember them.

Understanding configurable made jest.mock() make sense: If I'd just followed "use jest.mock() for fs" as a rule, I wouldn't have known why — or when to use spyOn instead. Now I know the underlying reason, and I can apply the right tool in future situations.

"Accidentally passing" tests are dangerous: A test that passes for the wrong reason gives false confidence. After this experience, I now double-check: does this test actually verify the behavior I care about, or did I get lucky?


Wrapping Up

This was my fifth TypeScript project — a Quiz CLI with CRUD and random playback.

Progress since the Household Budget:

  1. Used async/await to eliminate callback nesting in readline
  2. Constrained array length with tuple types — [option, option] instead of option[]
  3. Gave numeric IDs meaningful names with enum
  4. Understood the configurable property descriptor and chose jest.mock() over jest.spyOn for fs
  5. Discovered the "accidentally passing test" trap and learned to verify test correctness, not just test passage

Next up: an external API tool (weather API) to go deeper on fetch, async/await, and response type definitions.

Full learning log: LEARNING_LOG.md


This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.

Top comments (0)