086: Testing JavaScript: Unit, DOM, and Async Testing
Outcomes
By the end of this lesson, you can:
- separate pure logic from side effects to make testing easier;
- write focused unit tests;
- test error paths;
- test DOM behavior at the user-observable boundary;
- test Promises and async functions;
- use mocks/fakes sparingly;
- understand what should be tested at unit, integration, and end-to-end levels.
Testing Pyramid as a Heuristic
Different tests answer different questions.
Unit test: Does this small function behave correctly?
Integration test: Do several parts work together correctly?
End-to-end test: Can a user complete the real workflow?
Do not turn this into a rigid ratio. Choose the cheapest test that gives trustworthy confidence.
Pure Function Example
export function calculateTotal(items) {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
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 chosen by your project. The testing concepts are more important than memorizing one runner.
Arrange, Act, Assert
test("applies a valid discount", () => {
// Arrange
const cart = {
subtotal: 1000,
};
// Act
const total = applyDiscount(cart.subtotal, 10);
// Assert
expect(total).toBe(900);
});
This structure keeps intent visible.
Boundary Cases
Do not test only the happy middle.
For a quantity validator, include:
- valid positive integer;
- zero;
- negative;
- decimal;
- numeric string if allowed;
- invalid string;
- missing value.
Error Tests
test("rejects negative quantity", () => {
expect(() => {
normalizeQuantity(-1);
}).toThrow("Quantity");
});
Error behavior is part of the API contract.
Async Tests
test("loads products", async () => {
const products = await loadProducts(fakeFetch);
expect(products).toHaveLength(2);
});
If your function accepts dependencies as parameters, testing becomes easier.
export async function loadProducts(fetchFn = fetch) {
const response = await fetchFn("/api/products");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
Fake:
const fakeFetch = async () => ({
ok: true,
json: async () => [
{ id: 1 },
{ id: 2 },
],
});
DOM Test Philosophy
Test behavior users can observe rather than implementation details.
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 click the button and assert the output changes. It should not care about private local variable names.
Avoid Over-Mocking
If every collaborator is mocked, a test can prove that your mocks agree with your implementation while the real system still fails.
Prefer:
- pure logic tests;
- small fakes for external boundaries;
- integration tests for important module interactions;
- end-to-end tests for business-critical user flows.
Deterministic Tests
Avoid uncontrolled time/randomness/network.
Instead of:
function createId() {
return Math.random();
}
inject the source:
function createId(random = Math.random) {
return random();
}
Now the test can supply a deterministic function.
Testing Timers
Most test frameworks provide fake timers. Even without them, design timer logic behind a small boundary so application behavior is not inseparable from wall-clock time.
Regression Tests
When fixing a bug:
- reproduce it;
- write a test that fails because of it;
- fix the code;
- verify the test passes.
This converts a production lesson into durable protection.
What Not to Test
Avoid tests that simply duplicate language behavior:
expect([1, 2].length).toBe(2);
Test your rules and integration, not JavaScript itself.
Worked Example: Cart Service
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);
},
};
}
Tests should cover:
- valid add;
- invalid quantity;
- total;
- snapshot cannot mutate internal state.
Advanced Testing: Contract Tests, Integration Boundaries, and Coverage
Test contracts, not line counts
High code coverage does not guarantee useful tests. A test suite can execute every line without checking meaningful behavior.
Coverage is a signal for untested areas, not a quality score by itself.
API contract normalization
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. Downstream tests can then assume the normalized model.
Property-oriented thinking
Without adopting a property-testing library, you can still think in invariants.
For a discount calculator:
- total is never negative;
- 0% discount preserves subtotal;
- 100% discount produces zero;
- increasing discount should not increase final total.
These properties often reveal stronger tests than a few arbitrary examples.
DOM accessibility assertions
A UI test should include semantic behavior:
- can the control be found by role/name?
- does keyboard activation work?
- does an error message connect to the field?
- does focus move appropriately after a dialog opens?
Testing accessibility-relevant behavior improves both quality and test resilience.
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.
- Add regression tests for real bugs.
- Use end-to-end tests for critical workflows, not every helper function.
Exercises
Core
Write tests for a price calculator.
Practice
Test a validator's happy and error paths.
Professional Extension
Test an async repository with injected fetch, including success, HTTP failure, malformed JSON shape, and cancellation.
Recap
Testability is an architecture signal. Code with explicit inputs, outputs, state boundaries, and lifecycle behavior is usually easier both to test and to maintain.
