086: Testing JavaScript: Unit, DOM, and Async Testing
Outcomes
By the end of this lesson, you can:
- separate pure logic from side effects so the logic is easier to test;
- write focused unit tests;
- test error paths deliberately;
- test DOM behavior at the boundary a user can observe;
- test Promises and async functions;
- use mocks and fakes sparingly;
- decide what belongs in unit, integration, and end-to-end tests.
Testing Pyramid as a Heuristic
Different test types answer different questions, so they provide different kinds of confidence.
Unit test: Does this small function behave correctly in isolation?
Integration test: Do several parts of the application work together correctly?
End-to-end test: Can a user complete the actual workflow through the system?
The testing pyramid is useful as a heuristic, not as a rigid ratio that every project must obey. Choose the cheapest test that gives you trustworthy confidence in the behavior that matters. A small calculation may need only a unit test; an important interaction between a repository and a service may deserve an integration test; and a business-critical user journey is a good candidate for an end-to-end test.
Pure Function Example
Pure logic is often the simplest place to start. A pure function gets its result from its inputs and does not need to coordinate with the DOM, a network, a clock, or other mutable external state.
export function calculateTotal(items) {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
This function has a narrow contract: it calculates the sum of each item's price multiplied by its quantity. A focused test can exercise that contract without setting up a browser or a server.
Conceptual Vitest/Jest-style test:
import { expect, test } from "vitest";
import { calculateTotal } from "./cart.js";
test("calculates line totals", () => {
expect(
calculateTotal([
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 },
])
).toBe(250);
});
Use the test framework selected by your project. The important part here is not memorizing Vitest's or Jest's exact syntax. The transferable idea is to provide a small input, call the function, and assert the result that the contract promises.
Arrange, Act, Assert
The Arrange, Act, Assert pattern gives a test a visible shape. First prepare the inputs and state, then perform the operation under test, and finally verify the result.
test("applies a valid discount", () => {
// Arrange
const cart = {
subtotal: 1000,
};
// Act
const total = applyDiscount(cart.subtotal, 10);
// Assert
expect(total).toBe(900);
});
Keeping those three phases distinct makes the test's intent easy to scan. When a test fails, the structure also helps you decide whether the setup is wrong, the operation produced the wrong result, or the assertion does not describe the intended contract.
Boundary Cases
Do not test only the happy middle. Most defects appear at a boundary: an empty value, a limit, an unexpected type, or a value just outside the accepted range.
For a quantity validator, include at least:
- a valid positive integer;
- zero;
- a negative value;
- a decimal;
- a numeric string, if numeric strings are allowed;
- an invalid string;
- a missing value.
The exact expected result depends on the validator's contract. The point is to make that contract explicit instead of allowing the test suite to imply that only ordinary positive integers exist.
Error Tests
An error path is part of a function's public behavior, not an implementation detail to ignore.
test("rejects negative quantity", () => {
expect(() => {
normalizeQuantity(-1);
}).toThrow("Quantity");
});
This test checks that a negative quantity is rejected and that the thrown error communicates the relevant problem. Error behavior is part of the API contract: callers may depend on an exception, a rejected Promise, an error code, or another documented failure result. Test the form of failure that the caller is expected to handle.
Async Tests
Asynchronous code changes when the result becomes available, but the testing principle is the same: perform the operation, wait for its result, and assert the behavior.
test("loads products", async () => {
const products = await loadProducts(fakeFetch);
expect(products).toHaveLength(2);
});
If your function accepts dependencies as parameters, testing becomes easier. Passing a fetch function into the loader avoids making the test depend on a real network request.
export async function loadProducts(fetchFn = fetch) {
const response = await fetchFn("/api/products");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
The default parameter keeps the production call convenient, while the parameter gives a test a controlled boundary. The loader still checks the HTTP success signal before parsing the response, so an HTTP failure does not get mistaken for a valid product list.
Fake:
const fakeFetch = async () => ({
ok: true,
json: async () => [
{ id: 1 },
{ id: 2 },
],
});
This fake supplies the smallest response needed by the test. A useful async test suite should also cover rejection and other documented failure behavior, not only the successful response.
DOM Test Philosophy
DOM tests should focus on behavior users can observe rather than on private implementation details. A test that depends on a local variable name or a particular internal helper is likely to break during a harmless refactor. A test that interacts with the rendered control and checks the visible result is tied to the behavior that matters.
Given:
function mountCounter(root) {
let count = 0;
root.innerHTML = `
<button type="button">Increase</button>
<output>0</output>
`;
const button = root.querySelector("button");
const output = root.querySelector("output");
button.addEventListener("click", () => {
count += 1;
output.value = String(count);
});
}
A DOM test should locate the button, click it, and assert that the output changes. It should not care that the implementation uses a private local variable named count, or that the event handler is written in this particular form. The user-observable contract is that activating the Increase button changes the displayed count.
Avoid Over-Mocking
Mocks can isolate a unit, but isolation is not automatically confidence. If every collaborator is mocked, the test may only prove that the mocks agree with the implementation. The real system can still fail because the collaborators' actual contracts differ.
Prefer:
- tests of pure logic;
- small fakes at external boundaries;
- integration tests for important interactions between modules;
- end-to-end tests for business-critical user flows.
Use a mock when verifying a meaningful interaction is the behavior under test, not simply because replacing every dependency makes the test easier to arrange.
Deterministic Tests
Tests are easier to trust when they do not depend on uncontrolled time, randomness, or network conditions. A test that passes only when a random value falls within a certain range, or that occasionally loses a race against the clock, is expensive to diagnose.
Instead of hard-coding the random source inside the function:
function createId() {
return Math.random();
}
inject the source:
function createId(random = Math.random) {
return random();
}
Now the test can supply a deterministic function and assert exactly what should happen. The same boundary-injection idea applies to clocks, timers, network clients, and other sources of nondeterminism.
Testing Timers
Most test frameworks provide fake timers, which let a test advance time without waiting in real time. Even when fake timers are unavailable, keep timer logic behind a small boundary. Application behavior should not be inseparably coupled to wall-clock time.
That boundary makes it possible to test both the timer-driven behavior and the surrounding application logic without creating slow or flaky tests.
Regression Tests
When fixing a bug, turn the failure into a permanent test case:
- reproduce the bug;
- write a test that fails because of that bug;
- fix the code;
- verify that the test passes.
Writing the failing test first confirms that the test actually detects the defect. Once the fix is in place, the test converts a production lesson into durable protection against regression.
What Not to Test
Avoid tests that merely duplicate behavior guaranteed by the language or standard library:
expect([1, 2].length).toBe(2);
There is no application rule being checked here. Test your rules and your integrations, not JavaScript itself. A test should earn its place by protecting behavior your code is responsible for.
Worked Example: Cart Service
Here is a small cart service with private mutable state and a public API:
export function createCartService() {
let items = [];
return {
add(product, quantity = 1) {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error("Invalid quantity");
}
items = [
...items,
{
productId: product.id,
price: product.price,
quantity,
},
];
},
getTotal() {
return items.reduce(
(sum, item) =>
sum + item.price * item.quantity,
0
);
},
snapshot() {
return structuredClone(items);
},
};
}
The service keeps items private, validates quantities at the boundary, derives the total from the stored line items, and returns a clone from snapshot. That last detail matters: callers should be able to inspect a snapshot without receiving a reference that can mutate the service's internal state.
Tests should cover:
- adding a valid item;
- rejecting an invalid quantity;
- calculating the total;
- verifying that changing a snapshot cannot mutate internal state.
These tests exercise the public contract rather than reaching into the closure and inspecting items directly.
Advanced Testing: Contract Tests, Integration Boundaries, and Coverage
Test contracts, not line counts
High code coverage does not guarantee useful tests. A suite can execute every line and still fail to check meaningful behavior, such as an invalid input, a wrong return value, or an incorrect interaction between modules.
Use coverage to find areas that no test reaches. Treat it as a signal for investigation, not as a quality score by itself. A lower-coverage test that checks an important contract can be more valuable than a higher-coverage suite that only exercises ordinary paths.
API contract normalization
External APIs often use a shape that is convenient for the service providing it but awkward for the rest of your application. Normalize that shape at the boundary so downstream code can work with one consistent model.
Suppose an API returns:
{
"id": "P-1",
"unit_price": 100
}
Normalize at the boundary:
function normalizeProduct(raw) {
if (
raw === null ||
typeof raw !== "object" ||
typeof raw.id !== "string" ||
typeof raw.unit_price !== "number"
) {
throw new Error("Invalid product payload");
}
return {
id: raw.id,
unitPrice: raw.unit_price,
};
}
Test both valid and invalid payloads. Once this boundary is covered, downstream tests can assume the normalized model and do not need to repeat checks for the external spelling unit_price everywhere.
Property-oriented thinking
You do not need to adopt a property-testing library to think in invariants. Instead of choosing only a few arbitrary examples, ask what must remain true across a range of valid inputs.
For a discount calculator, useful properties include:
- the total is never negative;
- a 0% discount preserves the subtotal;
- a 100% discount produces zero;
- increasing the discount should not increase the final total.
These properties often lead to stronger tests than a handful of examples chosen without reference to the contract. They also help expose boundary mistakes and reversed comparisons.
DOM accessibility assertions
A UI test should cover semantic behavior as well as the final text or value. Include questions such as:
- can the control be found by its role and accessible name?
- does keyboard activation work?
- is an error message connected to the relevant field?
- does focus move appropriately after a dialog opens?
Testing accessibility-relevant behavior improves the user experience and usually makes tests more resilient. Queries based on what a user can identify are less coupled to incidental class names and DOM structure.
Best Practices
- Test behavior and contracts.
- Keep pure logic pure.
- Inject external dependencies at boundaries.
- Cover failures and boundary values.
- Avoid fragile tests tied to private implementation details.
- Add regression tests for real bugs.
- Use end-to-end tests for critical workflows, not for every helper function.
Exercises
Core
Write tests for a price calculator. Include the ordinary calculation and the boundary or error cases defined by the calculator's contract.
Practice
Test a validator's happy paths and error paths. Make the accepted inputs and rejected inputs explicit in the test names and assertions.
Professional Extension
Test an async repository with injected fetch. Cover a successful response, an HTTP failure, a response whose JSON shape is malformed, and cancellation.
Recap
Testability is an architecture signal. Code with explicit inputs and outputs, clear state boundaries, and deliberate lifecycle behavior is usually easier to test and easier to maintain. When a unit is difficult to test, that difficulty often points to hidden dependencies or responsibilities that could be separated.
