Skip to content
ManualMode guides

Debugging practice

JavaScript debugging practice problems with tests and answers

Five runnable JavaScript debugging problems with failing Node.js tests: falsy defaults, off-by-one pages, shared defaults, async forEach, and a stateful regex.

12 min read · Updated October 20, 2026

How to use these problems

Each problem is a short JavaScript function with a bug and a test file that exposes it. Run the tests, read the failure, find the cause, and make the smallest fix that turns the tests green. Answers are in a separate section further down, so you can work without spoilers.

ManualMode overview of five JavaScript bugs: a || default that turns 0 into 3, page arithmetic that skips the first page, a pushed shared default, an async forEach that returns 0, and a global regex that alternates true and false.
Each bug works on the typical input and fails at a boundary.
  1. Save a problem as its own file, for example problem1.test.mjs.
  2. Run node --test problem1.test.mjs. You need Node.js 20 or later; nothing to install.
  3. Before editing, write one sentence: which input fails, what was expected, and what actually happened.
  4. Change only the function. Do not edit the tests; they describe the contract.
  5. Rerun until every test passes, then compare your fix and explanation with the answer.

Try each problem without an AI assistant first. The skill being practiced is reading a failure and tracing it to a cause, which is exactly what gets skipped when a tool proposes the fix. Each problem was run on October 20, 2026, with a current Node.js release, and fails exactly as described in its answer.

Problem 1: an explicit zero disappears

The retry count must default to 3 when the option is missing, and an explicit value, including 0, must be kept.

import test from 'node:test';
import assert from 'node:assert/strict';

function retryCount(options) {
  return options.retries || 3;
}

test('uses the default when retries is missing', () => {
  assert.equal(retryCount({}), 3);
});

test('keeps an explicit retry count', () => {
  assert.equal(retryCount({ retries: 5 }), 5);
  assert.equal(retryCount({ retries: 0 }), 0);
});

One test passes and one fails. Which value is lost, and why does the default win?

Problem 2: the first page is missing

Pages are numbered from 1. Page 1 with size 2 must return the first two items; the last page may be shorter.

import test from 'node:test';
import assert from 'node:assert/strict';

// Pages are numbered from 1.
function getPage(items, page, size) {
  const start = page * size;
  return items.slice(start, start + size);
}

test('returns the first page', () => {
  assert.deepEqual(getPage([1, 2, 3, 4, 5], 1, 2), [1, 2]);
});

test('returns a short last page', () => {
  assert.deepEqual(getPage([1, 2, 3, 4, 5], 3, 2), [5]);
});

Both tests fail. Before fixing, predict what page 1 returns today.

Problem 3: tags leak between calls

The helper returns the given tags plus one new tag. Calling it must not change the defaults or affect the next call.

import test from 'node:test';
import assert from 'node:assert/strict';

const DEFAULT_TAGS = ['new'];

function withTag(tag, tags = DEFAULT_TAGS) {
  tags.push(tag);
  return tags;
}

test('adds a tag to the defaults', () => {
  assert.deepEqual(withTag('urgent'), ['new', 'urgent']);
});

test('does not leak tags between calls', () => {
  assert.deepEqual(withTag('later'), ['new', 'later']);
  assert.deepEqual(DEFAULT_TAGS, ['new']);
});

The first test passes on its own. The second fails because of something the first call did. What state survives between calls?

Problem 4: the total is always zero

The function must add the price of every ID. Prices come from an asynchronous lookup.

import test from 'node:test';
import assert from 'node:assert/strict';

const prices = { a: 5, b: 7 };
const price = async (id) => prices[id];

async function total(ids) {
  let sum = 0;
  ids.forEach(async (id) => {
    sum += await price(id);
  });
  return sum;
}

test('adds every price', async () => {
  assert.equal(await total(['a', 'b']), 12);
});

The test fails with 0. Every price lookup does run. Why does the function return before they finish?

Problem 5: the same check answers differently

The function must return true whenever the text contains a digit, no matter how many times it has been called before.

import test from 'node:test';
import assert from 'node:assert/strict';

const HAS_DIGIT = /\d/g;

function hasDigit(text) {
  return HAS_DIGIT.test(text);
}

test('detects a digit every time it is asked', () => {
  assert.equal(hasDigit('order 7'), true);
  assert.equal(hasDigit('order 8'), true);
});

test('rejects text without digits', () => {
  assert.equal(hasDigit('no numbers'), false);
});

The first call returns true and the second returns false, for inputs that both contain a digit. What does the regular expression remember?

Answers and explanations

Problem 1. || falls back for every falsy value, and 0 is falsy, so an explicit zero becomes 3. Actual 3, expected 0. Use nullish coalescing, which only falls back for null and undefined:

function retryCount(options) {
  return options.retries ?? 3;
}

Problem 2. The code treats the page number as zero-based. Page 1 starts at index 2 and returns [3, 4]; page 3 starts past the end and returns []. Convert the one-based page to a zero-based offset:

function getPage(items, page, size) {
  const start = (page - 1) * size;
  return items.slice(start, start + size);
}

Problem 3. A default parameter that refers to a module-level array reuses that same array on every call, and push mutates it. The second call returns ['new', 'urgent', 'later']. Return a new array instead of mutating the input:

function withTag(tag, tags = DEFAULT_TAGS) {
  return [...tags, tag];
}

This also stops the helper from changing an array a caller passes in, which the contract implies but the tests do not check. Add a test for that if it matters in your code.

Problem 4. forEach ignores the Promise returned by an async callback, so total returns 0 before any lookup has finished. Await each lookup in a loop, or collect them with Promise.all when they can run in parallel:

async function total(ids) {
  let sum = 0;
  for (const id of ids) {
    sum += await price(id);
  }
  return sum;
}

Problem 5. A regular expression with the g flag keeps lastIndex between test() calls. After matching 7 in order 7, the next search starts at index 7, past the 8 in order 8, so it returns false and resets. A yes-or-no check does not need the global flag:

const HAS_DIGIT = /\d/;

Behavior is documented in MDN for nullish coalescing, Array.prototype.forEach, and RegExp lastIndex.

Why these bugs show up in AI-generated code

All five are locally plausible: each function reads well, and a quick demo with typical inputs works. The failures appear only at a boundary, such as an explicit zero, the first page, a second call, or an asynchronous callback. Generated code often passes the obvious case and misses exactly those.

When you review a generated patch, look for the same patterns: || defaults on numbers and booleans, page or index arithmetic, mutation of defaults or inputs, async callbacks passed to array methods, and global regular expressions reused across calls. The AI code review checklist turns these into review steps, and mutation testing checks that your tests would catch them.

Keep practicing

ManualMode's Gym has debugging, code reading, and test-design reps in JavaScript that run in your browser, with tests, hints, and a review of how you worked. Three Gym reps and one Project rep from your own repository are free after signup, with no card required.

Prefer to start without an account? The free code review exercise asks you to approve or reject two synthetic AI-generated patches in about five minutes.

Start with evidence

Calibrate with three Gym reps, then verify one real Project task.

3 Gym + 1 Project reps free. Create an account; no card or public review required.

Start free