Free Online Code Editor
Learn what to test and how, by building a tiny test runner yourself. Then see how the same ideas map to Jest and Vitest, with async tests and edge cases.
Testing sounds like extra work until the day you change one line and something unrelated breaks. A test is just code that checks other code and shouts when the answer is wrong.
You don't need Jest, Vitest, or any setup to understand how it works. In this guide we'll write a tiny test runner in about ten lines, use it to test a real function, and then look at how frameworks do the same thing.
A test calls your code with a known input and checks that the output matches what you expect.
function add(a, b) {
return a + b;
}
console.log(add(2, 3) === 5 ? "pass" : "FAIL");passThat's a test. Everything else, such as runners, reports, and watch mode, is convenience built on top of this idea.
Let's make this nicer. We'll write two helpers: test to name and run a check, and expect to compare values.
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
}
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
}
},
};
}
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(" ✓", name);
} catch (error) {
failed++;
console.log(" ✗", name);
console.log(" ", error.message);
}
}A failed check throws an error. test catches it, prints the name, and carries on with the next test. That's the core of every test framework.
Here's something more realistic: a function that turns a title into a URL slug.
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
test("lowercases text", () => {
expect(slugify("Hello")).toBe("hello");
});
test("replaces spaces with dashes", () => {
expect(slugify("Learn JavaScript Fast")).toBe("learn-javascript-fast");
});
test("removes punctuation", () => {
expect(slugify("What's new?")).toBe("what-s-new");
});
test("trims dashes at the edges", () => {
expect(slugify(" --Hi-- ")).toBe("hi");
});
console.log(`${passed} passed, ${failed} failed`); ✓ lowercases text
✓ replaces spaces with dashes
✓ removes punctuation
✓ trims dashes at the edges
4 passed, 0 failedEach test has one job and a name that says what should be true. When one fails, the name tells you what broke before you read a single line of code.
The happy path is the easy part. Bugs usually live at the edges, so try the odd inputs:
test("empty string gives empty string", () => {
expect(slugify("")).toBe("");
});
test("only symbols gives empty string", () => {
expect(slugify("!!!")).toBe("");
});
test("collapses repeated spaces", () => {
expect(slugify("a b")).toBe("a-b");
});
test("keeps numbers", () => {
expect(slugify("Top 10 Tips")).toBe("top-10-tips");
});
console.log(`${passed} passed, ${failed} failed`); ✓ empty string gives empty string
✓ only symbols gives empty string
✓ collapses repeated spaces
✓ keeps numbers
8 passed, 0 failedGood questions to ask: What if it's empty? What if it's null? What about the biggest and smallest values? What about duplicates? What about unexpected characters?
A test that never fails is hard to trust, so let's see one fail on purpose.
test("this one is wrong on purpose", () => {
expect(slugify("Hello World")).toBe("hello_world");
}); ✗ this one is wrong on purpose
Expected "hello_world" but got "hello-world"The message shows what you expected and what you got. Good failure messages save real time.
Async tests need to wait. Make the test function async and await the result.
async function atest(name, fn) {
try {
await fn();
console.log(" ✓", name);
} catch (error) {
console.log(" ✗", name, "-", error.message);
}
}
function fakeFetchUser(id) {
return new Promise((resolve) => setTimeout(() => resolve({ id, name: "Ana" }), 20));
}
atest("loads a user", async () => {
const user = await fakeFetchUser(7);
expect(user.id).toBe(7);
}); ✓ loads a userNotice the fake fakeFetchUser. Tests shouldn't depend on real servers, which are slow and unreliable. Replace the real thing with a predictable stand-in.
Frameworks give you the same shape, plus nicer reports, mocking, and a watch mode that reruns tests when files change. This is the slug test written for them:
import { describe, it, expect } from "vitest";
import { slugify } from "./slugify.js";
describe("slugify", () => {
it("replaces spaces with dashes", () => {
expect(slugify("Learn JavaScript Fast")).toBe("learn-javascript-fast");
});
});If you understood the mini runner, you already understand these. describe groups tests, it and test are the same thing, and expect(...).toBe(...) works as you saw.
Test things that have logic: calculations, parsing, validation, formatting, and business rules. Test bugs you've fixed, so they stay fixed.
Don't bother testing the language itself, trivial getters, or the exact wording of a paragraph. Chasing 100% coverage often means writing tests that protect nothing.
Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.
Open the JavaScript editorUnderstand JavaScript promises and async/await step by step, including running tasks in parallel with Promise.all and the mistakes that cause silent bugs.
Learn how to find elements, change text and classes, create new nodes, and handle clicks with event delegation, plus how to avoid common XSS mistakes.
A hands-on tour of the modern JavaScript features that make everyday code shorter and safer, from destructuring and ?. to structuredClone and toSorted.