Testing Fundamentals

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Tests exist so you can change JavaScript code without guessing whether you broke it. A good test is an executable example: it sets up a situation, runs one behavior, and checks the result you meant to preserve.

By now you have tools that catch syntax, style, and type mistakes. Tests cover a different question: does the program still behave the way users and teammates depend on?

Tests are change insurance

Manual checking is fine when a program is tiny. You change a function, refresh the page, click around, and inspect a few console lines. The problem is that manual checks do not scale with the project.

Imagine a checkout helper:

function subtotalCents(items) {
  return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
}
 
const cart = [
  { name: "Notebook", priceCents: 1299, quantity: 2 },
  { name: "Pen", priceCents: 199, quantity: 3 },
];
 
console.log(subtotalCents(cart));

3195

That one example proves almost nothing by itself. What about an empty cart? Quantity 0? A later discount step? A future refactor from reduce to a loop? A test suite is the saved memory of those decisions. When the suite passes, you have evidence that the behaviors you named still hold.

This is the core reason teams write tests: not to prove code is perfect, but to make future changes less scary.

The testing pyramid

The testing pyramid is a practical way to spend testing effort. Keep many cheap tests at the base, fewer medium-cost tests in the middle, and a small number of expensive full-system tests at the top.

At the base are unit tests: focused checks for one function, class, or small module. They are fast, easy to run, and excellent for pure logic like totals, validation, sorting, parsing, and state updates.

In the middle are integration tests: checks that several pieces work together. A cart module plus a storage adapter, a fetch wrapper plus response parsing, or a form handler plus validation rules all fit here.

At the top are end-to-end tests: real browser flows such as "a user adds an item, checks out, and sees a receipt." They give high confidence, but they are slower, more brittle, and more expensive to debug.

What should you test first? Start where the behavior is important, easy to isolate, and likely to break during change:

  • pure functions with business rules
  • edge cases you already know are risky
  • bugs you just fixed, so they stay fixed
  • boundaries where outside data becomes trusted app data

Do not start by automating every click in the browser. A small pile of clear unit tests usually buys more confidence per minute than one giant end-to-end flow.

Arrange, Act, Assert

Most tests become easier to read when they follow Arrange, Act, Assert:

  • Arrange: create the data and setup for the example.
  • Act: run the behavior being tested.
  • Assert: check the result.

Here is that shape without any framework:

function subtotalCents(items) {
  return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
}
 
function assertEqual(actual, expected) {
  if (!Object.is(actual, expected)) {
    throw new Error(`Expected ${expected}, received ${actual}`);
  }
}
 
// Arrange
const cart = [
  { priceCents: 500, quantity: 2 },
  { priceCents: 250, quantity: 3 },
];
 
// Act
const total = subtotalCents(cart);
 
// Assert
assertEqual(total, 1750);
console.log("adds price times quantity for every item");

adds price times quantity for every item

Real test tools give you nicer reporting, watch mode, matchers, and failure output. The mental shape is the same. If you cannot point to the arrange, act, and assert in a test, the test is probably doing too much.

Name tests as sentences

A test name should read like a sentence about behavior:

  • adds price times quantity for every item
  • returns zero for an empty cart
  • starts free shipping at 5000 cents
  • rejects a missing email address

Weak names hide the reason the test exists:

  • test 1
  • cart works
  • checkout
  • should be correct

When a test fails at 4:57 p.m., the sentence is your first debugging clue. "starts free shipping at 5000 cents" tells you the contract. "cart works" tells you almost nothing.

In a project, the same idea usually sits behind one npm command:

npm test
 
# PASS adds price times quantity for every item
# PASS returns zero for an empty cart
# PASS starts free shipping at 5000 cents

The next lesson teaches the real Vitest version of this workflow. For now, practice the underlying habit: each test is one sentence of behavior plus one clear check.

Try it — a tiny test runner

Run this miniature test runner, then deliberately break qualifiesForFreeShipping by changing >= to >. The failing sentence tells you exactly what behavior changed.

javascript — live consolereal engine
⌘/Ctrl + Enter to run

Notice how boring the code under test is. That is a good sign. Tests are easiest to write when important behavior lives in small functions that accept data and return data.

TDD: red, green, refactor

Test-driven development is a tight loop:

  1. Red: write a small test that fails for the behavior you want.
  2. Green: write the simplest code that makes it pass.
  3. Refactor: improve the code while the test stays green.

The red step matters because it proves the test can catch the missing behavior. If a new test passes before you change production code, it may be testing the wrong thing.

Here is a red-phase sketch. The formatter is wrong, so the check prints false:

function formatCents(cents) {
  return `$${cents}`;
}
 
const actual = formatCents(1299);
const expected = "$12.99";
 
console.log(actual);
console.log(Object.is(actual, expected));

$1299, then false

Now make it green with the simplest honest implementation:

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
 
function formatCents(cents) {
  return usd.format(cents / 100);
}
 
console.log(formatCents(1299));
console.log(formatCents(5));

$12.99, then $0.05

Then refactor while preserving behavior:

const usdFormatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
 
function centsToDollars(cents) {
  return cents / 100;
}
 
function formatCents(cents) {
  return usdFormatter.format(centsToDollars(cents));
}
 
console.log([formatCents(1299), formatCents(5)].join(", "));

$12.99, $0.05

TDD is not a religion and not every line needs to begin with a test. It is most useful when the expected behavior is clear but the implementation is not: bug fixes, edge cases, business rules, and refactors.

You now have the testing vocabulary: pyramid, unit vs integration vs end-to-end, Arrange-Act-Assert, sentence names, and red-green-refactor. Next you will put those ideas into Vitest, the test runner that gives this tiny pattern a professional workflow.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion