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
- Build objects by composition with factory functions and write basic classes with constructors, methods, getters,
staticmembers, private fields, andextends. - Explain prototypes at the level needed to read class code.
- Use
try/catch/finallyandthrowwith the built-in error types and custom messages. - Decide where to throw and where to catch.
- Write Jest tests with
describe,test/it,expect, and common matchers, including tests for thrown errors. - Organize tests with the arrange-act-assert pattern and fixtures, and keep them deterministic.
- 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) {
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.
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); // truePoints to remember:
newcreates the object, runs the constructor, and returns the instance. Forgettingnewon a class throws aTypeError.- Methods are shared via the prototype rather than copied into every instance, which saves memory. Under the hood, a lookup such as
w.bookwalks the prototype chain from the object up toObject.prototype. - Inheritance with
extendsandsuper:
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 trueClass 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.
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:
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
}
}catchreceives the thrown value.- On ordinary completion or exception propagation,
finallyruns even whentryorcatchreturns. It is useful for cleanup. Avoid returning or throwing fromfinallyunless you intend to replace the earlier result or error. try/catchonly catches errors that happen while thetryblock is executing. To handle a later rejection,awaitit inside thetryor attach a promise.catchhandler; a synchronoustryaround 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
validateSeatscannot 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:
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.
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:
{
"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
// 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;
}// 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);
});
});describegroups related tests;test(aliasit) 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 callfn()directly, the exception escapes beforeexpectruns 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:
// 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,
}), {});
}// 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.
// 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 { 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.
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.
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:
toBewith objects or arrays; usetoEqual. - 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
thisin a method that is extracted and called alone. - Pitfall: forgetting
super()beforethisin 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:
toEqualfor structural comparison,toBeCloseTofor approximate numbers, andtoThrowwith 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:
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.
- 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. - 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.
export function earlyBirdPrice(price, eventDate, now) {
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
Errorsubclasses with clear messages and catch them at the boundary. - I use
finallyfor cleanup and never write an emptycatch. - 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
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 2: Programming with JavaScript (opens in a new tab)
- MDN: Classes (opens in a new tab)
- MDN: Control flow and error handling (opens in a new tab)
- MDN: Date (opens in a new tab)
- Jest: Getting Started (opens in a new tab)
- Jest: Using Matchers (opens in a new tab)
- Jest: Mock Functions (opens in a new tab)
- Jest: ES module configuration (opens in a new tab)
- Jest: Matcher reference (opens in a new tab)
- MDN: UTC date components (opens in a new tab)
