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
- 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) {
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:
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 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
}
}
catchreceives the thrown value.finallyalways runs, which makes it the right place to release a resource or hide a spinner.try/catchonly catches errors that happen while thetryblock is executing. A rejected promise is caught only if youawaitit inside thetry(previous lesson).
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. 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);
});
});
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:
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:
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 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 (
toEqualfor deep equality,toBeCloseTofor floats,toThrowwith 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.
- 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: 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
Errorsubclasses with clear messages and catch them at the boundary. - I use
finallyfor cleanup and never write an emptycatch. - 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
- 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)
