Skip to content

JavaScript Foundations: Values, Functions, Async, and Tests

Step 8 of 45

JavaScript Objects, Error Handling, and Jest Unit Tests

Introduction

JavaScript objects, deliberate error handling, and Jest unit tests make an interactive interface easier to maintain. This final chapter lesson aligns with Meta’s "Programming with JavaScript" course and develops three practical habits. 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 includes 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.

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

function createWorkshop(title, capacity) {
  if (!Number.isSafeInteger(capacity) || capacity < 1) {
    throw new RangeError("capacity must be a positive safe integer");
  }
  let booked = 0;               // private through the closure
  return {
    title,
    get capacity() { return capacity; },
    seatsLeft: () => capacity - booked,
    book(n) {
      if (!Number.isSafeInteger(n)) {
        throw new TypeError("seat count must be a safe integer");
      }
      if (n < 1 || n > capacity - booked) {
        throw new RangeError(`Invalid seat count; ${capacity - booked} seats left`);
      }
      booked += n;
      return capacity - booked;
    },
  };
}

The closure controls booked, and the getter exposes capacity without a public setter. Both implementations reject negative, fractional, or string seat counts before changing state. 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.

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

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

  get capacity() { return this.#capacity; }   // read-only public view

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

  book(n) {
    if (!Number.isSafeInteger(n)) {
      throw new TypeError("seat count must be a safe integer");
    }
    if (n < 1 || n > this.seatsLeft) {
      throw new RangeError(`Invalid seat count; ${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:
JavaScript
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

For related practice, study safe JavaScript number conversions.

Error types

Built-in JavaScript errors have a name and message; common engines also expose a stack trace, whose format is not standardized. 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.

JavaScript
function validateSeats(seats, capacity) {
  if (!Number.isSafeInteger(capacity) || capacity < 1) {
    throw new RangeError("capacity must be a positive safe integer");
  }
  if (!Number.isSafeInteger(seats)) {
    throw new TypeError("seats must be a safe 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:

JavaScript
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

JavaScript
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.
  • On ordinary completion or exception propagation, finally runs even when try or catch returns. It is useful for cleanup. Avoid returning or throwing from finally unless you intend to replace the earlier result or error.
  • try/catch only catches errors that happen while the try block is executing. To handle a later rejection, await it inside the try or attach a promise .catch handler; a synchronous try around an unawaited async call does not catch its later rejection.

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. A basic validity check is:

JavaScript
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.

For a booking field that accepts exactly YYYY-MM-DD, use a stricter function instead of relying on engine parsing. Construct UTC components, then compare them with the input so rollover is rejected. setUTCFullYear also avoids the special 1900-based interpretation of numeric years 0–99 in multi-argument date constructors.

JavaScript
function parseDate(text) {
  if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(text)) {
    throw new Error(`Invalid date: ${text}`);
  }
  const [year, month, day] = text.split("-").map(Number);
  const date = new Date(0);
  date.setUTCFullYear(year, month - 1, day);
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 ||
      date.getUTCDate() !== day) {
    throw new Error(`Invalid date: ${text}`);
  }
  return date; // UTC midnight; accepts only real YYYY-MM-DD calendar dates
}

For example, parseDate("2026-02-30") rejects, while parseDate("2028-02-29") succeeds. A date-only booking value is distinct from an event timestamp with an offset or time zone.

3. Testing with Jest

Jest is a JavaScript test runner with assertions, mocks, and reporting. These examples use Jest 30 and ES modules on a compatible, supported Node.js LTS release. In a separate practice project, install jest@30 as a development dependency and configure these package fields:

JSON
{
  "type": "module",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
  },
  "devDependencies": { "jest": "^30.0.0" }
}

Create jest.config.mjs with export default { testEnvironment: "node", transform: {} };. This follows Jest’s documented ESM setup, which remains experimental; do not mix it with an unspecified CommonJS or Babel setup. Jest makes describe, test, and expect available by default, but import the ESM jest object explicitly from @jest/globals when using mocks.

Commit the generated lockfile to keep the practice environment reproducible. Files named *.test.js or placed in __tests__ are discovered by default; the practice project's npm test runs them. Vite projects may instead use Vitest; it shares many Jest-style APIs but has its own configuration.

Anatomy of a test

JavaScript
// capacity.js
export function seatsLeft(capacity, booked) {
  if (!Number.isSafeInteger(capacity) || capacity < 0 ||
      !Number.isSafeInteger(booked) || booked < 0) {
    throw new RangeError("counts must be non-negative safe integers");
  }
  if (booked > capacity) throw new RangeError("booked exceeds capacity");
  return capacity - booked;
}
JavaScript
// 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:

JavaScript
// attendance.js
export function summarize(registrations) {
  return registrations.filter(r => r.paid).reduce((totals, r) => ({
    ...totals,
    [r.workshop]: (Object.hasOwn(totals, r.workshop) ? totals[r.workshop] : 0) + r.seats,
  }), {});
}
JavaScript
// attendance.test.js
import { summarize } from "./attendance.js";

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, with simple cases and little branching in assertions; use table-driven tests for related inputs.

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).

For related practice, study testing React through user behavior.

Stubs and mocks

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

JavaScript
// 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();
}
JavaScript
// loader.test.js
import { jest } from "@jest/globals";
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.

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

export function parseDate(text) {
  if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(text)) {
    throw new Error(`Invalid date: ${text}`);
  }
  const [year, month, day] = text.split("-").map(Number);
  const date = new Date(0);
  date.setUTCFullYear(year, month - 1, day);
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 ||
      date.getUTCDate() !== day) {
    throw new Error(`Invalid date: ${text}`);
  }
  return date; // UTC midnight; accepts only real YYYY-MM-DD calendar dates
}

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 date, a leap day, a non-leap-year February 29, February 30, an invalid string, and an empty string. Also verify that invalid capacity values are rejected by validateSeats.

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. "" fails the explicit format check and throws before a Date is constructed. February 30 and a February 29 in a non-leap year fail the component comparison.

Step 3: write the tests. Place the two exported functions in booking.js and the following suite in booking.test.js.

JavaScript
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("invalid capacity", () => {
  test.each([0, -1, 1.5, NaN])("rejects %p", (capacity) => {
    expect(() => validateSeats(1, capacity)).toThrow(RangeError);
  });
});

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

  test("accepts a real leap day", () => {
    expect(parseDate("2028-02-29").toISOString()).toBe("2028-02-29T00:00:00.000Z");
  });

  test.each(["nope", "", "2026-02-29", "2026-02-30"])("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 study 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 assertion that is neither awaited nor returned; the test can finish before it is checked, or leave an unhandled rejection.
  • Pitfall: tests that rely on the clock, random numbers, or the live network.
  • Study tip: choose matchers by behavior: toEqual for structural comparison, toBeCloseTo for approximate numbers, and toThrow with a wrapper.
  • Study tip: unit tests should isolate external services with controlled dependencies. Separate integration tests can exercise a real API in a suitable environment.

Mini case study: the flaky discount test

Situation. A teammate wrote a function and a test:

JavaScript
export function earlyBirdPrice(price, eventDate) {
  const daysAway = (eventDate - new Date()) / 86400000;
  return daysAway > 30 ? price * 0.9 : price;
}
JavaScript
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: make the current time an explicit parameter. The UI boundary can call earlyBirdPrice(price, eventDate, new Date()); tests pass a fixed value. Here “days” means elapsed 24-hour periods, not calendar days in a local business time zone.
JavaScript
export function earlyBirdPrice(price, eventDate, now) {
  const daysAway = (eventDate - now) / 86400000;
  return daysAway > 30 ? price * 0.9 : price;
}
JavaScript
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 distinguish an invalid Date value from an impossible calendar date and validate date-only inputs explicitly.
  • 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