Skip to content

JavaScript for Interactive Interfaces

Step 8 of 45

Objects, Errors, and JavaScript Tests

Introduction

The last lesson of the JavaScript chapter, aligned with course 2 of the Meta Front-End Developer pathway ("Programming with JavaScript"), covers three habits that separate a script from a maintainable program. First, modeling things as objects: grouping data and behavior with composition and classes. Second, handling errors on purpose: throwing meaningful errors in utilities and catching them where you can do something useful. Third, testing: writing small automated checks with Jest so that a change tomorrow does not silently break today's behavior. Course 2 itself ends with unit testing in Jest, and testing returns in the React chapters with React Testing Library.

The practice scenario is original: write tests for Harbor Workshops seat-capacity validation and for parsing invalid dates, using pure functions so no real service is needed.

Learning objectives

  1. Build objects by composition with factory functions and write basic classes with constructors, methods, getters, static members, private fields, and extends.
  2. Explain prototypes at the level needed to read class code.
  3. Use try/catch/finally and throw with the built-in error types and custom messages.
  4. Decide where to throw and where to catch.
  5. Write Jest tests with describe, test/it, expect, and common matchers, including tests for thrown errors.
  6. Organize tests with the arrange-act-assert pattern and fixtures, and keep them deterministic.
  7. Use a simple mock or stub to replace a service.

1. Objects, composition, and classes

Factory functions and composition

The simplest way to create objects with behavior is a factory function: a function that returns an object. Behavior is combined by composition, assembling small pieces, rather than inheriting from a parent.

const withBadge = (obj) => ({ ...obj, badge: true });

function createWorkshop(title, capacity) {
  let booked = 0;               // private through the closure
  return {
    title,
    capacity,
    seatsLeft: () => capacity - booked,
    book(n) {
      if (n > capacity - booked) {
        throw new Error(`Only ${capacity - booked} seats left`);
      }
      booked += n;
      return capacity - booked;
    },
  };
}

The variable booked cannot be changed from outside; only book can. This approach is common in functional-style JavaScript and in React code.

Classes

A class is a template for objects that share methods. It is syntax built on JavaScript's prototype mechanism.

class Workshop {
  #booked = 0;                              // private field: only usable inside the class

  constructor(title, capacity) {
    if (!Number.isInteger(capacity) || capacity < 1) {
      throw new RangeError("capacity must be a positive integer");
    }
    this.title = title;
    this.capacity = capacity;
  }

  get seatsLeft() {                         // getter: read like a property
    return this.capacity - this.#booked;
  }

  book(n) {
    if (n > this.seatsLeft) {
      throw new Error(`Only ${this.seatsLeft} seats left`);
    }
    this.#booked += n;
    return this.seatsLeft;
  }

  static from({ title, capacity }) {        // static: called on the class itself
    return new Workshop(title, capacity);
  }
}

const w = new Workshop("Pottery", 5);
console.log(w.book(2));        // 3
console.log(w.seatsLeft);      // 3
console.log(w instanceof Workshop); // true

Points to remember:

  • new creates the object, runs the constructor, and returns the instance. Forgetting new on a class throws a TypeError.
  • Methods are shared via the prototype rather than copied into every instance, which saves memory. Under the hood, a lookup such as w.book walks the prototype chain from the object up to Object.prototype.
  • Inheritance with extends and super:
class Online extends Workshop {
  constructor(title, capacity, url) {
    super(title, capacity);     // must be called before using `this`
    this.url = url;
  }
  describe() {
    return `${this.title} (online)`;
  }
}
const o = new Online("Web", 3, "https://example.org");
console.log(o.describe(), o.seatsLeft, o instanceof Workshop); // Web (online) 3 true

Class or composition?

Question Prefer composition / factory Prefer a class
Need private state with simple code? Yes (closure) Yes (#field)
Many instances sharing methods? Less efficient Yes
"Is-a" relationship with shared behavior? Combine pieces extends
React components in this course Functions Class components are legacy

Modern React uses function components and hooks, so you will rarely write classes in the UI. You still need to read them, because libraries, error types, and many tutorials use them. A general guideline is to favor composition: deep inheritance hierarchies are fragile, since a change in a parent affects every child.

2. Errors

Error types

JavaScript errors are objects with a name, a message, and a stack. Common built-in types:

Type Typical cause
Error Generic problem you define
TypeError Wrong type: calling a non-function, reading a property of undefined
RangeError A value outside the allowed range
ReferenceError Using an undeclared or uninitialized variable
SyntaxError Invalid code or invalid JSON text

You can throw any value, but you should always throw Error objects (or subclasses) because they carry a stack trace.

function validateSeats(seats, capacity) {
  if (!Number.isInteger(seats)) {
    throw new TypeError("seats must be an integer");
  }
  if (seats < 1 || seats > capacity) {
    throw new RangeError("seats out of range");
  }
  return true;
}

Custom errors extend Error so callers can tell them apart:

class BookingError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = "BookingError";
  }
}

The cause option preserves the original error when you re-throw a more meaningful one: throw new BookingError("Could not book", { cause: error }).

try / catch / finally

function readSettings(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    console.error("Bad settings file", error.message);
    return {};
  } finally {
    console.log("finished parsing"); // runs on success, on error, even after return
  }
}
  • catch receives the thrown value.
  • finally always runs, which makes it the right place to release a resource or hide a spinner.
  • try/catch only catches errors that happen while the try block is executing. A rejected promise is caught only if you await it inside the try (previous lesson).

Where to throw and where to catch

A useful rule: utilities throw; boundaries catch.

  • A low-level function like validateSeats cannot know what the user interface should say, so it throws a clear error and stops.
  • The code at the edge (a form handler, a route, a component) catches the error and decides: show a message, retry, or log.
  • Do not catch an error just to hide it (catch {} with an empty body). Silent failures are the hardest bugs to find.
  • Do not use exceptions for ordinary, expected outcomes inside loops when a simple condition would do. "User typed letters in a number field" is validation input; "the capacity was a negative number even though our own code set it" is a programming error.
  • Validate at the boundary (user input, network responses) and trust data internally.

Dates deserve special care

new Date("nope") does not throw; it creates an object whose time value is NaN, called an Invalid Date. You must check:

function parseDate(text) {
  const date = new Date(text);
  if (Number.isNaN(date.getTime())) {
    throw new Error(`Invalid date: ${text}`);
  }
  return date;
}
parseDate("2026-11-05").toISOString(); // "2026-11-05T00:00:00.000Z"

Two cautions. Date-only ISO strings ("2026-11-05") are read as UTC while date-time strings without an offset are read as local time, so results vary by time zone. And some impossible calendar dates, such as "2026-02-30", may be silently rolled to the next month by an engine instead of being rejected. When correctness matters, validate year, month, and day yourself or compare the result's parts with the input. Months in new Date(year, month, day) are zero-based: new Date(2026, 0, 31) is January.

3. Testing with Jest

Jest is a JavaScript test runner with built-in assertions, mocks, and reporting. In a project, install it as a development dependency and add a script:

{
  "scripts": { "test": "jest" },
  "devDependencies": { "jest": "^29.0.0" }
}

(Check the current Jest release when you set up a project; versions move on. Projects created with Vite often use Vitest, which has an almost identical API, while the pathway teaches Jest.) Files named *.test.js or located in __tests__ folders are discovered automatically; run them with npm test.

Anatomy of a test

// capacity.js
export function seatsLeft(capacity, booked) {
  if (booked > capacity) throw new RangeError("booked exceeds capacity");
  return capacity - booked;
}
// capacity.test.js
import { seatsLeft } from "./capacity.js";

describe("seatsLeft", () => {
  test("returns remaining seats", () => {
    // arrange
    const capacity = 10;
    const booked = 4;
    // act
    const result = seatsLeft(capacity, booked);
    // assert
    expect(result).toBe(6);
  });

  test("returns zero when full", () => {
    expect(seatsLeft(5, 5)).toBe(0);
  });

  test("throws when overbooked", () => {
    expect(() => seatsLeft(5, 6)).toThrow(RangeError);
  });
});
  • describe groups related tests; test (alias it) defines one.
  • expect(actual).matcher(expected) is an assertion. A failed assertion makes the test fail.
  • To test that a function throws, wrap the call in an arrow function: expect(() => fn()).toThrow(). If you call fn() directly, the exception escapes before expect runs and the test fails with the wrong error.

Common matchers

Matcher Checks Note
toBe(x) Object.is equality Primitives and same reference
toEqual(x) Deep equality of contents Objects and arrays
toStrictEqual(x) Deep equality including undefined properties and types Stricter
toBeTruthy() / toBeFalsy() Truthiness
toBeNull(), toBeUndefined() Exact values
toContain(x) Item in array or substring
toHaveLength(n) Length
toBeCloseTo(x, digits) Floats Use instead of toBe for 0.1 + 0.2
toThrow(...) Function throws Wrap in arrow
.not Negation expect(a).not.toBe(b)

A frequent beginner error: expect({ a: 1 }).toBe({ a: 1 }) fails because these are two different objects. Use toEqual.

Fixtures and organization

A fixture is the fixed data a test starts from. Keep it small and rebuild it for each test so tests cannot affect one another:

describe("attendance summary", () => {
  let registrations;

  beforeEach(() => {
    registrations = [
      { workshop: "Pottery", seats: 2, paid: true },
      { workshop: "Pottery", seats: 3, paid: true },
      { workshop: "Weaving", seats: 4, paid: false },
    ];
  });

  test("counts only paid seats", () => {
    expect(summarize(registrations)).toEqual({ Pottery: 5 });
  });

  test("does not mutate its input", () => {
    const copy = structuredClone(registrations);
    summarize(registrations);
    expect(registrations).toEqual(copy);
  });
});

Hooks: beforeEach and afterEach run around every test; beforeAll and afterAll run once per describe.

Good tests are:

  • Deterministic: same result every run. Do not depend on the current time, random numbers, or the network; pass them in as arguments or replace them.
  • Independent: one test never needs another to run first.
  • Focused: one behavior per test, with a name that reads like a sentence.
  • Readable: arrange-act-assert, no logic (loops, ifs) inside the test.

What to test

Test behavior at boundaries: smallest valid, largest valid, just outside, empty, and wrong-type inputs. For seat capacity of 10 that means 0 (invalid), 1 (valid), 10 (valid), 11 (invalid), 1.5 (invalid), and "2" (invalid type).

Stubs and mocks

To test code that depends on a service, replace the service with a controlled stand-in.

// loader.js
export async function loadEvents(fetchFn = fetch) {
  const response = await fetchFn("/api/events");
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}
// loader.test.js
import { loadEvents } from "./loader.js";

test("returns events from a successful response", async () => {
  const fakeFetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => [{ id: 1 }],
  });
  await expect(loadEvents(fakeFetch)).resolves.toEqual([{ id: 1 }]);
  expect(fakeFetch).toHaveBeenCalledWith("/api/events");
});

test("rejects on HTTP errors", async () => {
  const fakeFetch = jest.fn().mockResolvedValue({ ok: false, status: 503 });
  await expect(loadEvents(fakeFetch)).rejects.toThrow("HTTP 503");
});

jest.fn() creates a spy function; mockResolvedValue makes it return a resolved promise; toHaveBeenCalledWith verifies how it was called. Passing the dependency as a parameter (dependency injection) is the simplest way to make code testable without module-level tricks. For async tests, make the test function async and await the assertion (or return it); otherwise the test can finish before the assertion runs and pass wrongly.

4. Worked example: tests for seat capacity and invalid dates

Task. Write tests for these two functions.

export function validateSeats(seats, capacity) {
  if (!Number.isInteger(seats)) throw new TypeError("seats must be an integer");
  if (seats < 1 || seats > capacity) throw new RangeError("seats out of range");
  return true;
}

export function parseDate(text) {
  const date = new Date(text);
  if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${text}`);
  return date;
}

Step 1: list the cases. For validateSeats with capacity 5: valid low (1), valid high (5), zero, one above (6), a fraction (1.5), a numeric string ("2"). For parseDate: a valid ISO date, an invalid string, an empty string.

Step 2: predict results. 1 and 5 return true. 0 and 6 throw RangeError. 1.5 and "2" throw TypeError (the integer check runs first). "2026-11-05" gives a Date at UTC midnight. "nope" throws Invalid date: nope. "" also produces an invalid date and throws.

Step 3: write the tests.

import { validateSeats, parseDate } from "./booking.js";

describe("validateSeats (capacity 5)", () => {
  test.each([1, 5])("accepts %i", (seats) => {
    expect(validateSeats(seats, 5)).toBe(true);
  });

  test.each([0, 6])("rejects %i as out of range", (seats) => {
    expect(() => validateSeats(seats, 5)).toThrow(RangeError);
  });

  test.each([1.5, "2"])("rejects %p as a wrong type", (seats) => {
    expect(() => validateSeats(seats, 5)).toThrow(TypeError);
  });
});

describe("parseDate", () => {
  test("parses an ISO date", () => {
    expect(parseDate("2026-11-05").toISOString()).toBe("2026-11-05T00:00:00.000Z");
  });

  test.each(["nope", ""])("rejects %p", (text) => {
    expect(() => parseDate(text)).toThrow("Invalid date");
  });
});

test.each runs the same test body for each value, keeping the file short.

Why this beats weaker tests. A single test with only validateSeats(2, 5) passes even if the boundary logic is wrong. Calling validateSeats(0, 5) directly inside expect(...) would crash the test instead of asserting. Using toBe on parseDate("2026-11-05") against another new Date(...) would fail because two Date objects are different references, so compare toISOString() or getTime(). Mutating the capacity default or using today's date would make the tests non-deterministic.

5. Pitfalls and exam tips

  • Pitfall: toBe with objects or arrays; use toEqual.
  • Pitfall: calling the throwing function directly inside expect; wrap it.
  • Pitfall: catch (e) {} that hides failures.
  • Pitfall: new Date(text) assumed to throw on bad input.
  • Pitfall: using this in a method that is extracted and called alone.
  • Pitfall: forgetting super() before this in a subclass constructor (ReferenceError).
  • Pitfall: an async test without await; it passes before the assertion runs.
  • Pitfall: tests that rely on the clock, random numbers, or the live network.
  • Tip: questions about Jest usually ask which matcher fits (toEqual for deep equality, toBeCloseTo for floats, toThrow with a wrapper).
  • Tip: if an exam option says the test is "better because it calls the real API", reject it: unit tests should be isolated and deterministic.

Mini case study: the flaky discount test

Situation. A teammate wrote a function and a test:

export function earlyBirdPrice(price, eventDate) {
  const daysAway = (eventDate - new Date()) / 86400000;
  return daysAway > 30 ? price * 0.9 : price;
}
test("gives a discount", () => {
  expect(earlyBirdPrice(100, new Date("2026-12-24"))).toBe(90);
});

The test passes in October and fails in December, and the CI machine sometimes disagrees with local runs.

Questions. Why is the test flaky, and how do you fix both the function and the test?

Commented solution.

  1. The function reads the current time through new Date() inside, so its result depends on when it runs. That is a hidden input, and it makes the function impure and the test non-deterministic.
  2. Fix: pass the current date in as a parameter, with the clock as a default only at the call site.
export function earlyBirdPrice(price, eventDate, now = new Date()) {
  const daysAway = (eventDate - now) / 86400000;
  return daysAway > 30 ? price * 0.9 : price;
}
describe("earlyBirdPrice", () => {
  const event = new Date("2026-12-24T00:00:00Z");

  test("discounts more than 30 days ahead", () => {
    expect(earlyBirdPrice(100, event, new Date("2026-11-01T00:00:00Z"))).toBeCloseTo(90);
  });

  test("full price at 30 days or fewer", () => {
    expect(earlyBirdPrice(100, event, new Date("2026-11-24T00:00:00Z"))).toBe(100);
  });
});

toBeCloseTo is used for the discount because 100 * 0.9 involves floating-point multiplication. The second test sits exactly on the 30-day boundary: daysAway is 30, which is not greater than 30, so the full price applies. Boundary tests are the ones that catch > versus >= mistakes.

Key terms

  • Factory function: a function that returns a new object.
  • Composition: building behavior by combining small parts.
  • Class / instance: a template / an object created from it.
  • Prototype chain: the lookup path for properties and methods.
  • Exception: an error thrown and propagated up the call stack.
  • Unit test: an automated check of one small piece of code.
  • Matcher: a Jest assertion method such as toEqual.
  • Fixture: fixed data a test starts from.
  • Mock / stub: a controlled replacement for a dependency.
  • Dependency injection: passing dependencies in as arguments.

Summary checklist

  • I can create objects with factory functions and with classes, including private fields and extends.
  • I know when to prefer composition over inheritance.
  • I throw Error subclasses with clear messages and catch them at the boundary.
  • I use finally for cleanup and never write an empty catch.
  • I check dates with Number.isNaN(date.getTime()).
  • I write Jest tests with describe, test, expect, and arrange-act-assert.
  • I pick the right matcher (toBe, toEqual, toBeCloseTo, toThrow).
  • I test boundaries and error paths, not only the happy path.
  • I keep tests deterministic by injecting time and services.

Official sources