Code is easy to test when you can call a function with some input and check what comes out. It is hard to test when the answer depends on the page, the network, the current time or a global variable. This lesson shows how to structure the course dashboard so that most of its logic can be tested in milliseconds, without a browser.
The problem: everything in one handler
A first version of the dashboard's filter often looks like this:
document.querySelector('#filters').addEventListener('change', async () => {
const topic = document.querySelector('#topic').value;
const response = await fetch('data/courses.json');
const courses = await response.json();
const list = document.querySelector('#course-list');
list.replaceChildren();
for (const course of courses) {
if (topic === 'all' || course.topic === topic) {
const item = document.createElement('li');
item.textContent = course.title;
list.append(item);
}
}
});
It works, but to test whether filtering by topic is correct you need a page with the right elements, a server returning JSON, and a way to fire a change event. The interesting logic, one if, is buried among side effects.
Separate logic from side effects
A side effect is anything a function does besides returning a value: changing the DOM, making a request, writing to storage, logging, reading the clock. Split code into two layers:
- A core of pure functions. Given the same input they return the same output and change nothing else. These hold your rules: filtering, sorting, searching, formatting, validation.
- A thin shell that does the side effects: reads the form, calls
fetch, calls the core, writes the result to the page.
The core of the dashboard becomes its own module:
// courses.js
export function filterCourses(courses, { topic = 'all', search = '' } = {}) {
const query = search.trim().toLowerCase();
return courses.filter((course) =>
(topic === 'all' || course.topic === topic) &&
course.title.toLowerCase().includes(query)
);
}
export function sortCourses(courses, key) {
const sorters = {
title: (a, b) => a.title.localeCompare(b.title),
updated: (a, b) => b.updated.localeCompare(a.updated),
lessons: (a, b) => a.lessons - b.lessons,
};
const compare = sorters[key];
if (!compare) {
throw new RangeError(`Unknown sort key "${key}"`);
}
return courses.toSorted(compare);
}
updated holds ISO date strings such as "2026-03-14", which sort correctly as text. toSorted returns a new array and leaves the input alone, which keeps the function pure. It is supported in all current browsers and in Node.js 20.
These functions know nothing about the DOM or the network. You can call them from a test, from the console or from a Node.js script.
Inject dependencies
Some logic genuinely needs the outside world. Loading the courses needs fetch. A cache needs the current time. Rather than reaching for globals inside the function, pass the dependency in. This is dependency injection, and in JavaScript it is usually just a parameter with a sensible default.
// api.js
export async function loadCourses({ fetchFn = fetch, url = 'data/courses.json' } = {}) {
const response = await fetchFn(url);
if (!response.ok) {
throw new Error(`Could not load courses: HTTP ${response.status}`);
}
return response.json();
}
export function createCache({ maxAgeMs, now = Date.now }) {
let value;
let storedAt = -Infinity;
return {
get() {
return now() - storedAt < maxAgeMs ? value : undefined;
},
set(newValue) {
value = newValue;
storedAt = now();
},
};
}
In the browser, callers use the defaults and never notice. In a test, you pass a fake fetchFn that returns a canned response, or a fake now that you control. No network, no waiting for real time to pass.
The shell stays thin
With the logic moved out, the page code only wires things together:
// main.js
import { loadCourses } from './api.js';
import { filterCourses, sortCourses } from './courses.js';
const form = document.querySelector('#filters');
const list = document.querySelector('#course-list');
const status = document.querySelector('#status');
function render(courses) {
list.replaceChildren(...courses.map((course) => {
const item = document.createElement('li');
item.textContent = `${course.title} (${course.lessons} lessons)`;
return item;
}));
status.textContent = `${courses.length} courses shown`;
}
const allCourses = await loadCourses();
function update() {
const data = new FormData(form);
const filtered = filterCourses(allCourses, {
topic: data.get('topic'),
search: data.get('search'),
});
render(sortCourses(filtered, data.get('sort')));
}
form.addEventListener('input', update);
update();
#status is a role="status" live region and the form contains labelled topic, search and sort controls. Nothing here is complicated enough to hide a subtle bug, which is the point. The shell is checked by trying the page or with browser-based tests; the core is checked with fast unit tests.
Testing with node:test
Node.js has a built-in test runner, so you need no extra packages to start. Add a package.json that marks the project as ES modules:
{
"name": "course-dashboard",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
}
}
Then write tests next to the code:
// courses.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filterCourses, sortCourses } from './courses.js';
const courses = [
{ id: 1, title: 'CSS Grid', topic: 'css', level: 'intermediate', lessons: 8, updated: '2026-02-01' },
{ id: 2, title: 'Closures', topic: 'javascript', level: 'intermediate', lessons: 5, updated: '2026-03-10' },
{ id: 3, title: 'Async JavaScript', topic: 'javascript', level: 'advanced', lessons: 12, updated: '2026-01-20' },
];
test('filters by topic', () => {
const result = filterCourses(courses, { topic: 'javascript' });
assert.deepEqual(result.map((c) => c.id), [2, 3]);
});
test('search ignores case and surrounding spaces', () => {
const result = filterCourses(courses, { search: ' GRID ' });
assert.deepEqual(result.map((c) => c.id), [1]);
});
test('sorts by most recently updated without changing the input', () => {
const result = sortCourses(courses, 'updated');
assert.deepEqual(result.map((c) => c.id), [2, 1, 3]);
assert.deepEqual(courses.map((c) => c.id), [1, 2, 3]);
});
test('rejects an unknown sort key', () => {
assert.throws(() => sortCourses(courses, 'price'), RangeError);
});
Run them with:
npm test
node --test finds files whose names end in .test.js (and several other patterns), runs them and reports each test as passing or failing with a diff when values differ. node:assert/strict makes deepEqual use strict comparison.
Testing code with dependencies
Fakes for injected dependencies are ordinary functions:
// api.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadCourses, createCache } from './api.js';
test('throws a clear error on HTTP failure', async () => {
const fakeFetch = async () => ({ ok: false, status: 503 });
await assert.rejects(
loadCourses({ fetchFn: fakeFetch }),
{ message: 'Could not load courses: HTTP 503' }
);
});
test('cache expires after maxAgeMs', () => {
let time = 1_000;
const cache = createCache({ maxAgeMs: 500, now: () => time });
cache.set(['cached']);
assert.deepEqual(cache.get(), ['cached']);
time += 499;
assert.deepEqual(cache.get(), ['cached']);
time += 1;
assert.equal(cache.get(), undefined);
});
The test controls time directly, so an expiry of five minutes or five hours would be checked just as instantly. Time is just a variable. node:test also has built-in mocking, including mock timers, which is useful when you cannot change a function's signature. Injecting dependencies usually keeps tests simpler.
Habits that keep code testable
- Keep modules small and focused. One module for data rules, one for API calls, one for rendering. A test file then imports only what it checks.
- Return values instead of writing to shared state. A function that returns a filtered list is easier to test than one that updates a global
visibleCourses. - Avoid work at import time. A module that queries the DOM or calls
fetchas soon as it is imported cannot be imported by a test. Export functions and call them from the entry point. - Pass options objects.
filterCourses(courses, { topic })can grow new options without breaking existing calls or tests. - Throw specific errors. A
RangeErrorwith a clear message is easy to assert on and easy to debug. - Test behaviour, not implementation. Assert on what callers see. Tests that check internal details break every time you refactor.
If something is hard to test, treat that as design feedback. It usually means a function is doing two jobs, such as computing a result and showing it. Split it, and both halves get easier.
DOM-level tests, browser test runners and end-to-end tools such as Playwright belong to a dedicated testing course. The structure in this lesson is what makes them manageable: the fewer rules live in the shell, the fewer slow browser tests you need.
Summary
- Keep rules in pure functions and side effects in a thin shell.
- Inject dependencies such as
fetchand the clock as parameters with defaults. - Avoid side effects at import time so tests can import any module.
- Node.js has a built-in test runner:
node:testwithnode:assert/strict, run withnode --test. - Fakes for injected dependencies are plain functions, so tests stay fast and deterministic.