⚡

How to Write Your First JavaScript Tests (No Framework Needed)

JavaScript4 min readUpdated October 2026

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.

What a test actually is

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");
pass

That's a test. Everything else, such as runners, reports, and watch mode, is convenience built on top of this idea.

Build a mini test runner

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.

Test a real function

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 failed

Each 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.

Think about edge cases

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 failed

Good 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?

What a failure looks like

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.

Testing async code

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 user

Notice the fake fakeFetchUser. Tests shouldn't depend on real servers, which are slow and unreliable. Replace the real thing with a predictable stand-in.

The same tests in Jest or Vitest

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.

What to test and what to skip

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.

Habits that make tests useful

  • One idea per test. If a test fails, you should know why immediately.
  • Name tests as sentences. "returns an empty list when nothing matches" beats "test 3".
  • Keep tests independent. One test shouldn't rely on another having run first.
  • Make them fast. Slow tests don't get run.

Key takeaways

  • A test is code that calls your code and checks the result.
  • Frameworks add convenience, but the idea is the same as the ten-line runner above.
  • Test edge cases, not just the happy path.
  • Replace slow or unreliable dependencies with fakes.

Try it yourself

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 editor

Related Articles