Testing JavaScript with Jest & Mocha
Reviewed & published by Brayan K
Master automated testing to catch bugs before production. Learn unit tests, mocking, async testing, API testing, and test-driven development with Jest and Mocha.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Testing frameworks require Node.js. To practice:
- Install Node.js
- Jest: npm install --save-dev jest
- Mocha + Chai: npm install --save-dev mocha chai
What You'll Learn
- Jest & Mocha test structure
- Async testing with async/await
- Mocking functions & modules
- API testing with Supertest
- Test coverage & TDD
Why Automated Testing?
Testing transforms "I think this works" into "I know this works." A solid test suite gives you confidence to refactor, fast feedback on bugs, and protection against regressions.
Types of Tests
- Unit tests — Single function/module
- Integration tests — Multiple units together
- E2E tests — Full app through UI
Jest vs Mocha
- Jest — All-in-one, zero-config
- Mocha — Minimal, highly configurable
- Both excellent — choice depends on ecosystem
Jest Basics
Jest is all-in-one: test runner, assertion library, mocking, coverage, and watch mode built in. Perfect for React and modern JavaScript projects.
// Jest - Basic Test Structure
// math.js - Code to test
function sum(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
module.exports = { sum, divide };
// math.test.js - Jest tests
const { sum, divide } = require("./math");
// describe() groups related tests
describe("sum()", () => {
// test() or it() defines a single test case
test("adds two positive numbers", () => {
expect(sum(2, 3)).toBe(5);
});
test("works with negative numbers", () => {
expect(sum(-2, -3)).toBe(-5);
});
test("handles zero", () => {
expect(sum(5, 0)).toBe(5);
});
});
describe("divide()", () => {
test("divides numbers correctly", () => {
expect(divide(10, 2)).toBe(5);
});
test("returns decimal results", () => {
expect(divide(7, 2)).toBe(3.5);
});
// Testing exceptions
test("throws when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
// Run with: npm test
// Jest output:
// PASS ./math.test.js
// sum()
// ✓ adds two positive numbers (2 ms)
// ✓ works with negative numbers (1 ms)
// ✓ handles zero (1 ms)
// divide()
// ✓ divides numbers correctly (1 ms)
// ✓ returns decimal results (1 ms)
// ✓ throws when dividing by zero (1 ms)Worked Example: Build the Test Runner Yourself
Real Jest runs from the command line, which means the snippet above cannot execute on this page — and reading tests you have never watched run is how testing stays abstract. So here is the fix: describe, test and expect written out in about 30 lines of ordinary JavaScript. This is genuinely most of what a test framework is. Once you have read it, "the test failed" stops being magic: it means a matcher threw an error and the runner caught it.
One of the four tests fails on purpose. Look at the failure line — the exact expectation, the exact result — because that message is the entire value proposition of automated testing.
// WORKED EXAMPLE - build the test runner, then use it.
// Jest is not magic: describe, test and expect are ordinary functions, and a
// "failing test" is just a thrown error that the runner catches. Here is a
// 30-line version, so you can run real tests right here in the editor.
let passed = 0;
let failed = 0;
// describe() only groups and labels. It runs the callback immediately.
function describe(name, fn) {
console.log(name);
fn();
}
// test() runs one case and catches whatever it throws.
function test(name, fn) {
try {
fn(); // the assertions live in here
passed++;
console.log(" PASS " + name);
} catch (err) {
failed++; // a thrown error IS a failed test
console.log(" FAIL " + name + " -> " + err.message);
}
}
// expect() returns an object of matchers. Each matcher throws when unhappy.
function expect(actual) {
return {
toBe(expected) { // === , for numbers/strings/booleans
if (actual !== expected) {
throw new Error("expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual));
}
},
toEqual(expected) { // deep compare, for objects/arrays
const got = JSON.stringify(actual);
const want = JSON.stringify(expected);
if (got !== want) throw new Error("expected " + want + ", got " + got);
},
toThrow() { // here "actual" must be a FUNCTION
try {
actual();
} catch (e) {
return; // it threw, which is what we wanted
}
throw new Error("expected it to throw, but it returned normally");
}
};
}
// ---------- The code under test ----------
function divide(a, b) {
if (b === 0) throw new Error("Cannot divide by zero");
return a / b;
}
function initials(fullName) {
return fullName.split(" ").map(part => part[0].toUpperCase()).join(".");
}
// ---------- The tests ----------
describe("divide()", () => {
test("divides two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
test("rejects a zero divisor", () => {
// Note the arrow function: you hand over the call UNCALLED, so the
// matcher can run it inside its own try/catch.
expect(() => divide(1, 0)).toThrow();
});
});
describe("initials()", () => {
test("handles two names", () => {
expect(initials("ada lovelace")).toBe("A.L");
});
test("handles three names", () => {
// This one FAILS on purpose, so you can see what a failure looks like.
expect(initials("mary jackson smith")).toBe("M.J");
});
});
console.log(passed + " passed, " + failed + " failed");
// ✅ Expected output:
// divide()
// PASS divides two numbers
// PASS rejects a zero divisor
// initials()
// PASS handles two names
// FAIL handles three names -> expected "M.J", got "M.J.S"
// 3 passed, 1 failed
//
// That last line is the whole point of a test suite: it told you the exact
// input, the exact expectation and the exact result, without you opening the app.What real Jest adds on top: finding your test files, running each file in a fresh module registry, dozens more matchers, mocking, coverage and parallel workers. The core loop you just read — group, run, catch, report — is the same.
🎯 Your Turn: Write the Assertions
The function under test is written for you and it is correct — so every test should pass. Four blanks are marked ___: two are values you have to work out, two are matcher names you have to choose. Getting a FAIL is not a broken exercise; read what it wanted and fix your number.
// 🎯 YOUR TURN - fill in the four blanks marked ___
// The tiny runner from the worked example is included below, squashed down so
// it is out of your way. Your job is only the four test cases.
let passed = 0, failed = 0;
function describe(name, fn) { console.log(name); fn(); }
function test(name, fn) {
try { fn(); passed++; console.log(" PASS " + name); }
catch (e) { failed++; console.log(" FAIL " + name + " -> " + e.message); }
}
function expect(actual) {
return {
toBe(want) { if (actual !== want) throw new Error("expected " + JSON.stringify(want) + ", got " + JSON.stringify(actual)); },
toEqual(want) { const g = JSON.stringify(actual), w = JSON.stringify(want); if (g !== w) throw new Error("expected " + w + ", got " + g); },
toThrow() { try { actual(); } catch (e) { return; } throw new Error("expected it to throw, but it returned normally"); }
};
}
// ---------- The code under test (already written, and correct) ----------
function applyDiscount(price, code) {
if (price <= 0) throw new Error("price must be positive");
if (code === "HALF") return price / 2;
if (code === "TENOFF") return price - 10;
return price; // an unknown code changes nothing
}
describe("applyDiscount()", () => {
test("halves the price for HALF", () => {
expect(applyDiscount(80, "HALF")).toBe(___); // 👉 what should 80 become?
});
test("takes 10 off for TENOFF", () => {
expect(applyDiscount(80, "TENOFF")).___(70); // 👉 which matcher compares with === ?
});
test("ignores an unknown code", () => {
expect(___).toBe(80); // 👉 call applyDiscount(80, "BOGUS")
});
test("rejects a price of zero", () => {
expect(() => applyDiscount(0, "HALF")).___(); // 👉 which matcher expects a throw?
});
});
console.log(passed + " passed, " + failed + " failed");
// ✅ Expected output once the blanks are filled:
// applyDiscount()
// PASS halves the price for HALF
// PASS takes 10 off for TENOFF
// PASS ignores an unknown code
// PASS rejects a price of zero
// 4 passed, 0 failed
//
// A test that FAILS is not a broken exercise - read its message, it tells you
// exactly which number it wanted.Mocha + Chai
Mocha is a minimal test runner paired with Chai for assertions. This combination gives you fine-grained control over your testing stack.
// Mocha + Chai - Flexible Testing Setup
// Install: npm install --save-dev mocha chai
// math.js (same code)
function sum(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
module.exports = { sum, divide };
// test/math.test.js - Mocha + Chai tests
const { expect } = require("chai");
const { sum, divide } = require("../math");
describe("sum()", () => {
it("adds two positive numbers", () => {
const result = sum(2, 3);
expect(result).to.equal(5);
});
it("works with negative numbers", () => {
expect(sum(-2, -3)).to.equal(-5);
});
});
describe("divide()", () => {
it("divides numbers correctly", () => {
expect(divide(10, 2)).to.equal(5);
});
it("throws when dividing by zero", () => {
expect(() => divide(10, 0)).to.throw("Cannot divide by zero");
});
});
// Chai assertion styles:
// expect(value).to.equal(5) // BDD style
// value.should.equal(5) // Should style
// assert.equal(value, 5) // Assert style
// package.json script: "test": "mocha"
// By default, Mocha looks for tests in test/ folderTesting Async Code
Real apps are full of async code: API calls, timers, database queries. Both Jest and Mocha handle async testing with async/await and Promises.
// Testing Asynchronous Code
// async.js - Async functions to test
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function delayedValue(value, ms) {
return new Promise(resolve => {
setTimeout(() => resolve(value), ms);
});
}
module.exports = { fetchUser, delay, delayedValue };
// async.test.js - Testing async code with Jest
// Method 1: async/await (PREFERRED)
test("async/await style", async () => {
const data = await delayedValue("hello", 100);
expect(data).toBe("hello");
});
// Method 2: Return a Promise
test("returns a promise", () => {
return delayedValue("hello", 100).then(data => {
expect(data).toBe("hello");
});
});
// Method 3: done callback (legacy)
test("uses done callback", done => {
delayedValue("hello", 100).then(data => {
expect(data).toBe("hello");
done(); // Signal completion
});
});
// Testing Promise rejections
test("handles rejection", async () => {
await expect(fetchUser(999)).rejects.toThrow("User not found");
});
// Alternative syntax
test("rejects with error", () => {
return expect(fetchUser(999)).rejects.toBeInstanceOf(Error);
});
// With Mocha + Chai (using chai-as-promised)
// npm install chai-as-promised
const chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);
it("resolves correctly", async () => {
await expect(delayedValue("hi", 100)).to.eventually.equal("hi");
});
it("rejects with error", async () => {
await expect(fetchUser(999)).to.be.rejectedWith("User not found");
});Mocking with Jest
Mocking isolates the code under test by replacing dependencies with controlled substitutes. Jest includes powerful mocking capabilities built-in.
// Mocking Functions, Modules & APIs
// --- JEST MOCKING ---
// 1. Mock functions
const mockFn = jest.fn();
mockFn("hello");
mockFn("world");
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith("hello");
// 2. Mock return values
const mockGet = jest.fn()
.mockReturnValue(10) // Always returns 10
.mockReturnValueOnce(5) // Returns 5 first time
.mockResolvedValue({ id: 1 }) // Returns Promise
.mockRejectedValue(new Error()); // Rejects Promise
// 3. Mock an entire module
// api.js
export function getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
// userService.test.js
jest.mock("./api", () => ({
getUser: jest.fn(() => Promise.resolve({ id: 1, name: "Boopie" }))
}));
import { getUser } from "./api";
import { processUser } from "./userService";
test("processes user correctly", async () => {
const result = await processUser(1);
expect(getUser).toHaveBeenCalledWith(1);
expect(result.name).toBe("Boopie");
});
// 4. Mock fetch globally
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: "Boopie" })
})
);
test("fetches user data", async () => {
const user = await fetchUser(1);
expect(fetch).toHaveBeenCalledWith("/api/users/1");
expect(user.name).toBe("Boopie");
});
// 5. Spy on existing methods
const obj = {
method: (x) => x * 2
};
const spy = jest.spyOn(obj, "method");
obj.method(5);
expect(spy).toHaveBeenCalledWith(5);
spy.mockRestore(); // Restore original💡 When to Mock: Mock external services (APIs, databases), slow operations, unpredictable values (random, time), and modules with side effects. Keep tests fast, isolated, and deterministic.
Sinon for Mocha
Sinon provides spies, stubs, and mocks for Mocha. It's the companion library that gives Mocha the same mocking power Jest has built-in.
// Sinon - Spies, Stubs & Mocks for Mocha
// npm install --save-dev sinon
const sinon = require("sinon");
const { expect } = require("chai");
// 1. Spies - Track function calls without changing behavior
describe("Spies", () => {
it("tracks calls", () => {
const callback = sinon.spy();
callback("hello");
callback("world");
expect(callback.calledTwice).to.be.true;
expect(callback.calledWith("hello")).to.be.true;
expect(callback.firstCall.args[0]).to.equal("hello");
});
it("spies on existing method", () => {
const obj = { method: (x) => x * 2 };
const spy = sinon.spy(obj, "method");
obj.method(5);
expect(spy.calledOnce).to.be.true;
expect(spy.returnValues[0]).to.equal(10);
spy.restore();
});
});
// 2. Stubs - Replace functions with controlled behavior
describe("Stubs", () => {
it("replaces function behavior", () => {
const api = { getUser: () => {} };
const stub = sinon.stub(api, "getUser")
.returns({ id: 1, name: "Boopie" });
const user = api.getUser(1);
expect(user.name).to.equal("Boopie");
expect(stub.calledWith(1)).to.be.true;
stub.restore();
});
it("stubs async functions", async () => {
const api = { fetchUser: async () => {} };
const stub = sinon.stub(api, "fetchUser")
.resolves({ id: 1, name: "Boopie" });
const user = await api.fetchUser(1);
expect(user.name).to.equal("Boopie");
});
it("throws errors", () => {
const api = { save: () => {} };
sinon.stub(api, "save").throws(new Error("DB Error"));
expect(() => api.save()).to.throw("DB Error");
});
});
// 3. Mocks - Pre-programmed expectations
describe("Mocks", () => {
it("verifies expectations", () => {
const api = { save: () => {} };
const mock = sinon.mock(api);
mock.expects("save")
.once()
.withArgs({ id: 1 });
api.save({ id: 1 });
mock.verify(); // Throws if expectations not met
mock.restore();
});
});
// 4. Fake timers
describe("Fake Timers", () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it("controls time", () => {
const callback = sinon.spy();
setTimeout(callback, 1000);
expect(callback.called).to.be.false;
clock.tick(1000);
expect(callback.called).to.be.true;
});
});Testing Timers
Test time-based logic like debounce, throttle, countdowns, and delays using fake timers. Control time programmatically for instant, reliable tests.
// Testing Timers & Time-Based Logic
// timer.js - Code with timers
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
function countDown(seconds, onTick, onComplete) {
let remaining = seconds;
const interval = setInterval(() => {
remaining--;
onTick(remaining);
if (remaining === 0) {
clearInterval(interval);
onComplete();
}
}, 1000);
return interval;
}
module.exports = { debounce, countDown };
// timer.test.js - Jest fake timers
const { debounce, countDown } = require("./timer");
// Enable fake timers
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe("debounce()", () => {
test("delays function execution", () => {
const fn = jest.fn();
const debounced = debounce(fn, 500);
debounced();
expect(fn).not.toHaveBeenCalled();
// Fast-forward time
jest.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
test("resets timer on repeated calls", () => {
const fn = jest.fn();
const debounced = debounce(fn, 500);
debounced();
jest.advanceTimersByTime(300);
debounced(); // Reset!
jest.advanceTimersByTime(300);
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
});
describe("countDown()", () => {
test("calls onTick for each second", () => {
const onTick = jest.fn();
const onComplete = jest.fn();
countDown(3, onTick, onComplete);
jest.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(2);
jest.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(1);
jest.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(0);
expect(onComplete).toHaveBeenCalled();
});
});
// Testing Date
test("mocks current date", () => {
const mockDate = new Date("2024-01-15");
jest.setSystemTime(mockDate);
expect(new Date().getFullYear()).toBe(2024);
expect(new Date().getMonth()).toBe(0); // January
});API Testing with Supertest
Test Express/Node.js APIs by making HTTP requests and asserting responses. Supertest makes API testing clean and expressive.
// Testing Node.js APIs with Supertest
// npm install --save-dev supertest
// app.js - Express application
const express = require("express");
const app = express();
app.use(express.json());
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
app.get("/users", (req, res) => {
res.json(users);
});
app.get("/users/:id", (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
app.post("/users", (req, res) => {
if (!req.body.name) {
return res.status(400).json({ error: "Name is required" });
}
const newUser = { id: users.length + 1, name: req.body.name };
users.push(newUser);
res.status(201).json(newUser);
});
module.exports = app;
// app.test.js - API tests
const request = require("supertest");
const app = require("./app");
describe("GET /users", () => {
test("returns list of users", async () => {
const response = await request(app).get("/users");
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].name).toBe("Alice");
});
});
describe("GET /users/:id", () => {
test("returns a specific user", async () => {
const response = await request(app).get("/users/1");
expect(response.status).toBe(200);
expect(response.body.name).toBe("Alice");
});
test("returns 404 for missing user", async () => {
const response = await request(app).get("/users/999");
expect(response.status).toBe(404);
expect(response.body.error).toBe("User not found");
});
});
describe("POST /users", () => {
test("creates a new user", async () => {
const response = await request(app)
.post("/users")
.send({ name: "Charlie" })
.set("Content-Type", "application/json");
expect(response.status).toBe(201);
expect(response.body.name).toBe("Charlie");
expect(response.body.id).toBeDefined();
});
test("returns 400 without name", async () => {
const response = await request(app)
.post("/users")
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe("Name is required");
});
});Test Coverage
Coverage reports show which lines, branches, and functions your tests execute. Use coverage thresholds to enforce testing standards.
// Test Coverage - Measuring Code Quality
// Jest coverage: npm test -- --coverage
// Or add to package.json:
{
"jest": {
"collectCoverage": true,
"coverageReporters": ["text", "html", "lcov"],
"coverageDirectory": "coverage"
}
}
// Coverage output example:
// ---------------|---------|----------|---------|---------|
// File | % Stmts | % Branch | % Funcs | % Lines |
// ---------------|---------|----------|---------|---------|
// All files | 85.71 | 80 | 83.33 | 85.71 |
// math.js | 100 | 100 | 100 | 100 |
// string.js | 71.43 | 60 | 66.67 | 71.43 |
// ---------------|---------|----------|---------|---------|
// Coverage metrics:
// - Statements: % of code statements executed
// - Branches: % of if/else paths tested
// - Functions: % of functions called
// - Lines: % of lines executed
// Enforce coverage thresholds
{
"jest": {
"coverageThreshold": {
"global": {
"statements": 80,
"branches": 80,
"functions": 80,
"lines": 80
},
"./src/critical/**/*.js": {
"statements": 100
}
}
}
}
// Mocha coverage with NYC (Istanbul CLI)
// npm install --save-dev nyc
// package.json:
{
"scripts": {
"test": "nyc mocha"
},
"nyc": {
"reporter": ["text", "html"],
"check-coverage": true,
"lines": 80,
"functions": 80,
"branches": 80
}
}
// Run: npm test
// View HTML report: open coverage/index.htmlTest-Driven Development (TDD)
TDD means writing tests before implementation. This drives better design and ensures every feature has test coverage from the start.
🎯 Mini-Challenge: TDD a Slug Maker
You have read the TDD loop; now run it. The tests are written and the implementation is not. Run the block once before writing any code and watch all four fail — that red phase is not a formality, it is proof your tests are really calling your code and not quietly passing on nothing.
Then write slugify(title) and run it again. There is no starter logic, only the brief in the comments.
Testing Best Practices
Follow these patterns to write tests that are reliable, maintainable, and actually catch bugs instead of just passing.
Key Takeaways
Core Concepts
- ✓ describe/test/expect structure
- ✓ Async testing with async/await
- ✓ Mocking functions and modules
- ✓ Fake timers for time-based code
- ✓ API testing with Supertest
Best Practices
- ✓ Test behavior, not implementation
- ✓ Use Arrange-Act-Assert pattern
- ✓ Test edge cases and errors
- ✓ Keep tests fast and isolated
- ✓ Aim for 80%+ coverage on critical code
Practice quiz
In Jest, which function groups related tests together?
- expect()
- mock()
- describe()
- assert()
Answer: describe(). describe() groups related tests; test()/it() define individual cases.
Which Jest matcher checks that a function throws an error?
- .toThrow()
- .toBe()
- .toEqual()
- .toContain()
Answer: .toThrow(). expect(() => fn()).toThrow() asserts that the function throws.
What is the PREFERRED way to test async code in the lesson?
- The done callback
- Synchronous loops
- setTimeout polling
- async/await
Answer: async/await. async/await is presented as the preferred, cleanest style for async tests.
What does jest.fn() create?
- A real API call
- A mock function you can assert calls on
- A new test file
- A coverage report
Answer: A mock function you can assert calls on. jest.fn() creates a mock function whose calls can be tracked and asserted.
What is the purpose of jest.useFakeTimers()?
- To control time so debounce/throttle/countdowns can be tested instantly
- To make tests slower
- To delete timers
- To mock fetch
Answer: To control time so debounce/throttle/countdowns can be tested instantly. Fake timers let you advance time programmatically for reliable time-based tests.
In Sinon, what is a 'spy' used for?
- Replacing a function entirely
- Throwing errors
- Tracking function calls without changing behavior
- Mocking the DOM
Answer: Tracking function calls without changing behavior. A spy records how a function was called while leaving its behavior intact.
Which library is used to test Express/Node.js APIs in the lesson?
- DOMPurify
- Supertest
- Chalk
- Webpack
Answer: Supertest. Supertest makes HTTP requests against an app and lets you assert on responses.
What does the 'Branches' coverage metric measure?
- Git branches tested
- The number of files
- Lines of comments
- The % of if/else paths tested
Answer: The % of if/else paths tested. Branch coverage is the percentage of conditional (if/else) paths exercised by tests.
What is the correct order of the TDD workflow?
- Code, then test, then ship
- Write a failing test, write minimum code to pass, then refactor
- Refactor, test, document
- Test only at the end
Answer: Write a failing test, write minimum code to pass, then refactor. TDD: write a failing test, make it pass with minimal code, then refactor while green.
Which testing best practice does the lesson recommend?
- Test private implementation details
- Put all assertions in one test
- Test observable behavior, not implementation
- Use names like test1
Answer: Test observable behavior, not implementation. Test behavior rather than internal/private details so tests survive refactors.
Continue this course
- Previous: Intro to TypeScript & Strong Typing Concepts
- Next: Building Simple SPAs with Vanilla JavaScript — Build a single-page application with client-side routing and state
- Quick reference: JavaScript cheat sheet