Course: JavaScript Advanced
Lesson 17: Testing Mindset
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Explain what makes a function easy or difficult to test, and restructure code to improve testability
- Write unit tests for pure functions using the Arrange-Act-Assert pattern
- Distinguish between what should and should not be unit tested, and why
Frontend Development Context
The ability to write testable code is one of the most reliable indicators of a developer's architectural maturity. A function that is easy to test is almost always better-designed than one that is not — it has fewer dependencies, clearer inputs and outputs, and more predictable behavior. In professional frontend teams, code without tests is considered riskier to change, harder to refactor, and more likely to introduce regressions.
Testing knowledge at the advanced level is not just about knowing which testing library to install. It is about recognizing why pure functions are trivially testable, why functions with side effects or hidden dependencies require mocking and setup, and how to design code so that the critical business logic lives in pure, testable functions even when the overall feature involves DOM manipulation, API calls, or browser APIs.
Explanation
A unit test verifies that a single, isolated piece of code — usually one function — behaves correctly for a given input. The fundamental structure is Arrange-Act-Assert (AAA): set up the inputs and context (Arrange), call the code under test (Act), and verify the output matches expectations (Assert). Good unit tests are fast, independent (not relying on other tests or shared state), and focused (testing one thing at a time).
Pure functions are the easiest thing to test. Given no setup, no mocks, and no teardown — just a function call and an assertion. A function that takes an input and returns an output deterministically can be tested exhaustively by enumerating its input/output pairs. This is why the advice to write pure functions is not just philosophical: it has a direct practical payoff in testability.
Impure functions — those with side effects, hidden dependencies, or non-deterministic behavior — require more effort to test. A function that reads from the DOM, calls Date.now(), or makes an HTTP request needs test doubles (mocks, stubs, spies) to replace those dependencies with predictable versions. The more dependencies a function has, the more setup is needed for each test.
What to test is as important as how to test. Unit tests should focus on logic — data transformations, validation rules, calculation algorithms, state transitions. They should not test implementation details (the names of private variables, the order of internal function calls) because those change frequently during refactoring. Test the behavior — what the function returns given inputs — not the implementation.
What not to test at the unit level: trivial code (a one-line getter), framework internals (whether React's useState triggers a re-render), and third-party library behavior. Integration tests and end-to-end tests cover the connections between units; unit tests cover the units themselves.
The act of writing tests also improves design. If you find a function hard to test, that's feedback: the function probably does too much, has too many dependencies, or mixes concerns. Restructure it, and testability improves as a consequence of better design.
Code Example
This example shows pure utility functions that are trivially testable, a function refactored from untestable to testable by separating logic from side effects, and AAA-structured test examples.
// --- Pure functions: trivially testable ---
function calculateDiscount(price, discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError("Discount must be between 0 and 100");
}
return parseFloat((price * (1 - discountPercent / 100)).toFixed(2));
}
function formatCurrency(amount, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
function groupByCategory(items) {
return items.reduce((acc, item) => {
const key = item.category;
acc[key] = acc[key] ? [...acc[key], item] : [item];
return acc;
}, {});
}
// --- Unit tests (using plain assertions — no library needed) ---
function assert(description, condition) {
if (!condition) throw new Error(`FAIL: ${description}`);
console.log(`PASS: ${description}`);
}
function assertThrows(description, fn) {
try {
fn();
throw new Error(`FAIL: ${description} — no error was thrown`);
} catch (err) {
if (err.message.startsWith("FAIL:")) throw err;
console.log(`PASS: ${description} (threw: ${err.message})`);
}
}
// Arrange-Act-Assert for calculateDiscount
assert(
"10% discount on $100 returns $90.00",
calculateDiscount(100, 10) === 90.00
);
assert(
"0% discount returns original price",
calculateDiscount(50, 0) === 50.00
);
assert(
"100% discount returns $0",
calculateDiscount(200, 100) === 0
);
assertThrows(
"negative discount throws RangeError",
() => calculateDiscount(100, -5)
);
assertThrows(
"discount over 100 throws RangeError",
() => calculateDiscount(100, 110)
);
// Arrange-Act-Assert for groupByCategory
const items = [
{ name: "Apple", category: "fruit" },
{ name: "Carrot", category: "veggie" },
{ name: "Banana", category: "fruit" },
];
const grouped = groupByCategory(items);
assert("fruit group has 2 items", grouped.fruit.length === 2);
assert("veggie group has 1 item", grouped.veggie.length === 1);
assert("fruit includes Apple", grouped.fruit[0].name === "Apple");
// --- Refactoring for testability ---
// BEFORE: untestable — logic is mixed with DOM side effects
function updateDashboard_bad() {
const items = JSON.parse(localStorage.getItem("cart") || "[]");
const total = items.reduce((sum, i) => sum + i.price, 0);
document.querySelector("#total").textContent = `$${total.toFixed(2)}`;
}
// AFTER: testable core logic + thin side-effect wrapper
function calculateCartTotal(items) { // pure — easy to test
return items.reduce((sum, i) => sum + i.price, 0);
}
function renderTotal(element, total) { // side effect — integration test
element.textContent = `$${total.toFixed(2)}`;
}
function updateDashboard_good(items, element) { // thin orchestrator
const total = calculateCartTotal(items);
renderTotal(element, total);
}
const cartItems = [{ price: 29.99 }, { price: 15.00 }, { price: 5.01 }];
assert("cart total is 50.00", calculateCartTotal(cartItems) === 50.00);
Code Explanation
calculateDiscount, formatCurrency, and groupByCategory are pure functions. They take inputs, produce outputs, and have no side effects. Testing them requires only a function call and an assertion — no mocking, no DOM setup, no async handling.
The assert and assertThrows helpers demonstrate the AAA pattern without any library. Each test call is self-contained: the description says what it tests, the condition is the assertion. assertThrows verifies that an error is thrown, and distinguishes between the "FAIL" we throw (test framework signal) and the actual error from the code under test.
The refactoring example shows the most important testing mindset technique. updateDashboard_bad mixes logic (calculating the total) with side effects (reading localStorage, writing to the DOM). It is untestable without mocking the browser environment. The refactored version separates calculateCartTotal — a pure function that's trivially testable — from renderTotal — a side-effect function tested at the integration level. The orchestrator updateDashboard_good is thin enough that it doesn't need a dedicated unit test.
This pattern — extract pure logic, keep side effects in thin wrappers — is the single most impactful architectural change for improving testability.
Pattern Highlights
✅ Positive Pattern: Extract business logic from functions that also perform DOM manipulation or API calls — the logic can then be unit tested in isolation, and the integration between logic and side effects is tested separately.
⚠️ Neutral Note: 100% code coverage is not the goal — testing trivial code (a one-line getter) or testing implementation details produces tests that slow down refactoring without catching real bugs. Focus tests on logic with multiple branches, edge cases, and failure modes.
❌ Negative Pattern: Do not write tests that assert on implementation details like which internal function was called — these tests break every time you refactor internal structure even if the observable behavior is unchanged, creating maintenance burden without quality benefit.
Common Mistakes
Mistake 1: Testing implementation, not behavior
Testing whether a specific internal function was called (a spy-based test) couples the test to implementation details.
// Fragile — this test breaks if you rename doCalculation, even if the result is the same
const spy = jest.spyOn(module, "doCalculation");
module.processOrder(order);
expect(spy).toHaveBeenCalled(); // tests HOW, not WHAT
Test the return value or observable side effect instead: expect(module.processOrder(order)).toEqual(expectedResult). The test will survive internal refactoring.
Mistake 2: Making tests depend on each other
Tests that share mutable state or run in a specific order create fragile test suites where a failure in one test causes failures in unrelated tests.
let sharedCart = [];
test("add item", () => {
sharedCart.push({ price: 10 });
expect(sharedCart.length).toBe(1);
});
test("remove item", () => {
sharedCart.pop(); // relies on the previous test having run first
expect(sharedCart.length).toBe(0);
});
Each test should set up its own data from scratch. Use beforeEach to reset state, or declare test data inside each test.
Mistake 3: Trying to unit test impure functions without mocking their dependencies
Testing a function that calls fetch or reads from localStorage directly in a unit test requires the real network or browser environment to exist, making tests slow, flaky, and environment-dependent.
test("loadUser fetches user data", async () => {
const user = await loadUser(1); // calls real fetch — slow, flaky
expect(user.name).toBe("Alice");
});
Either refactor to separate the fetch from the logic (and unit test the logic), or use a mock for fetch in your test framework that returns a controlled response without hitting the network.
Quick Recap
- Pure functions are the easiest things to test — they require no mocking, no setup, and no teardown; the test is just a function call and an assertion.
- The Arrange-Act-Assert structure organizes each test into three phases: set up inputs, invoke the code under test, and assert on the result.
- Improve testability by separating pure logic from side effects — extract data transformation into pure functions, keep DOM manipulation and API calls in thin wrappers, and test the pure functions directly.
- Test observable behavior (return values, state changes) not implementation details (which internal function was called) — behavior tests survive refactoring while implementation tests break on every internal change.