Level 5, Professional Practice, Chapter 22 of 24
Testing Your Code
Why this matters
You've probably already tested your code the manual way: reload the page, click around, check the console, repeat. That approach is fine for a five-minute project, but imagine a site with dozens of functions and interactions. Every time you fix one thing, you'd have to remember to manually re-check everything else that might have broken, which is slow, unreliable, and easy to skip under deadline pressure. Automated tests solve this by encoding your manual checks into code that runs in seconds and never forgets a step.
Testing might sound like an advanced, optional practice, but it's really just an extension of skills you already have: writing functions, making assertions with comparisons, and reasoning about expected versus actual output. This chapter shows you how to formalize that reasoning into a real test suite, so you can change and grow your code with confidence instead of fear.
The lesson
Why automated tests exist
Consider a function you've likely written some version of before: one that calculates a discounted price, validates a form field, or formats a date. Each time you touch that function later, whether to fix a bug or add a feature, there's a risk you break something that used to work. Automated tests are small programs that call your code with known inputs and check that the output matches what you expect. Run them after every change, and you'll know immediately if something broke, rather than discovering it days later when a user reports strange behavior. This safety net is what lets experienced developers refactor confidently instead of leaving working-but-messy code untouched out of fear.
Tests are usually organized into three broad categories. Unit tests check a single function or small piece of logic in isolation, like your discount calculator returning the right number for a given price and percentage. Integration tests check that several pieces work correctly together, like a form validator and a submit handler cooperating properly. End-to-end tests simulate an entire user journey in a real browser, such as filling out a signup form and confirming a success message appears. Most projects have far more unit tests than end-to-end tests, since unit tests are faster to run and easier to pinpoint when something fails.
Writing your first tests with Vitest
Vitest is a modern JavaScript testing tool that fits naturally into projects built with tools like Vite, which you encountered in the previous chapter. A test file typically imports the function being tested, calls it with sample inputs inside a test or it block, and uses expect to assert what the result should be. If the assertion fails, Vitest reports exactly which test failed, what was expected, and what was actually returned, which is far faster than manually re-checking behavior in the console every time.
1// applyDiscount.js2export function applyDiscount(price, percentOff) {3 if (percentOff < 0 || percentOff > 100) {4 throw new Error("percentOff must be between 0 and 100");5 }6 return Number((price - price * (percentOff / 100)).toFixed(2));7}89// applyDiscount.test.js10import { describe, test, expect } from "vitest";11import { applyDiscount } from "./applyDiscount.js";1213describe("applyDiscount", () => {14 test("applies a standard percentage discount", () => {15 expect(applyDiscount(100, 20)).toBe(80);16 });1718 test("returns the original price with 0% off", () => {19 expect(applyDiscount(50, 0)).toBe(50);20 });2122 test("throws for an invalid percentage", () => {23 expect(() => applyDiscount(50, 150)).toThrow();24 });25});describegroups related tests together under a readable label.test(or its aliasit) defines one specific case with a clear description.expect(actual).toBe(expected)checks exact equality for primitives like numbers and strings.expect(fn).toThrow()verifies that calling a function under certain conditions raises an error, just like the validation you wrote in earlier chapters.
Testing asynchronous and DOM-related code
Since you've already worked with async/await and fetch, you'll be glad to know Vitest handles asynchronous tests naturally: mark your test function async and await the code under test, just as you would anywhere else. For code that calls a real API, it's common practice to mock the network request rather than hitting a live server during tests, since real servers can be slow, rate-limited, or unavailable, and you want tests to be fast and predictable. A mock replaces the real fetch call with a fake version that returns controlled, known data, letting you test how your code handles both successful responses and error cases without relying on the outside world.
1import { test, expect, vi } from "vitest";2import { getUserName } from "./getUserName.js";34test("returns the user's name from the API response", async () => {5 global.fetch = vi.fn(() =>6 Promise.resolve({7 ok: true,8 json: () => Promise.resolve({ name: "Priya Shah" }),9 })10 );1112 const name = await getUserName(42);13 expect(name).toBe("Priya Shah");14});You won't write tests for every single line of code you produce, and that's normal; the goal is to prioritize the logic that's easy to get subtly wrong, like calculations, validation rules, and data transformations, over trivial code that simply displays a value. A useful habit is writing a test the moment you fix a bug: it documents the exact scenario that broke and guarantees it can never silently reappear later.
Try it yourself
This sandbox includes a tiny hand-written test runner written in plain JavaScript (no library needed) so you can see what tools like Vitest are doing under the hood. Add at least two more test cases for the isValidEmail function, including one that should fail so you can see a failure report.
Chapter boss project
Boss Project: Test-Driven Utility Library
Write a small library of at least four pure utility functions — things like formatting currency, validating a password's strength, calculating age from a birthdate, or slugifying a title into a URL-friendly string. For at least two of them, try writing the test cases before writing the function itself, deciding what counts as correct behavior first.
Set up Vitest in a project (using the workflow from the previous chapter) and write a full test suite covering typical inputs, edge cases like empty strings or zero, and invalid inputs that should throw errors. Run the suite with npm run test and get every test passing.
Deliberately introduce a bug into one function afterward, run your tests, and confirm they catch it. Then fix the bug and confirm the suite passes again. Note what you observed in a short comment.
- Install Vitest into an existing Vite project with `npm install -D vitest`, then add a `"test": "vitest run"` script to package.json.
- Edge cases are often more revealing than typical cases: try empty strings, negative numbers, and unusually large values.
- If you're unsure what to test, write down in plain language what the function should do in three or four scenarios, then translate each sentence into a test.
- Keep the functions pure (no DOM access, no fetch) at first; pure functions are the easiest and most valuable place to start testing.
Level-up checklist
Tick these off once each one is true for you.
