Phase 6 · Advanced & ProfessionalModule 36~46 min read

Testing JavaScript

Build confidence with unit, integration, DOM, and end-to-end tests using modern test design.

What you'll learn

Tests are executable evidence about behavior. A balanced strategy keeps most logic fast and isolated while protecting integration boundaries and a few critical user journeys.

By the end of this lesson, you'll be able to:

  • Choose unit, integration, and end-to-end scopes
  • Use test doubles without testing implementation details
  • Test asynchronous and DOM behavior

Core mental model

Use this decision table as a compact reference. Focus on what each tool means and when it earns its place in production code.

ConceptWhat it meansDecision rule
Unit testFast check of one focused behaviorUse for domain rules and pure transformations
Integration testSeveral real boundaries work togetherUse for database, HTTP, DOM, and module collaboration
End-to-end testCritical journey through the deployed shapeKeep few, stable, and user-centered

Professional workflow

Build the behavior in small, observable steps. Each step should leave something you can inspect or test.

  1. Describe the test strategy boundary: inputs, outputs, state, timing, and expected failures.
  2. Implement the smallest correct path with names that expose intent.
  3. Add edge cases and failure handling before introducing abstractions.
  4. Verify behavior with realistic data and one deliberately adversarial example.
  5. Refactor only after the observable behavior is protected.

Make behavior observable

Before optimizing or abstracting, make inputs, outputs, state changes, timing, and failure paths visible. JavaScript becomes much easier to reason about when hidden work is exposed.

Guided code lab

Test behavior at a public boundary

The test states a domain example instead of mirroring internal lines.

pricing.test.js
import { describe, expect, it } from "vitest";
import { total } from "./pricing.js";

describe("total", () => {
  it("applies quantity and tax", () => {
    const lines = [{ price: 10, quantity: 2 }];
    expect(total(lines, 0.1)).toBe(22);
  });
  it("returns zero for an empty cart", () => {
    expect(total([], 0.1)).toBe(0);
  });
});

Control an async dependency

Dependency injection keeps the test deterministic without replacing the function under test.

course-service.test.js
it("returns a normalized course", async () => {
  const request = async () => ({ id: 7, display_name: "JavaScript" });
  const service = createCourseService({ request });
  await expect(service.get(7)).resolves.toEqual({ id: 7, name: "JavaScript" });
});

Production practice

Contract

Write down the test strategy quality target, ownership boundary, evidence, and acceptable tradeoffs.

Verification

Use repeatable automated checks plus a realistic end-to-end scenario; include failure and recovery behavior.

Operations

Track a small set of user-centered signals and revisit the decision when evidence changes.

Common failure mode

Mocking every collaborator makes tests pass while the real system's contracts drift apart.

Independent workshop

Add a layered test suite to the full-stack course application.

Your finished workshop must include:

  • Unit tests for domain rules
  • Integration tests for API and persistence
  • One critical accessible browser journey

Definition of done

Demonstrate the happy path and at least two edge cases, keep responsibilities separated, and add a short note explaining one design choice.

Recap & quick check

Key takeaways

  • Tests protect behavior
  • Scope follows risk and feedback speed
  • Doubles replace boundaries selectively
  • Critical journeys deserve end-to-end evidence

Quick check

1. Which tests should usually be most numerous?

2. What should a test double replace?

3. What makes async tests reliable?

Keep the workshop. Later modules deliberately build on these decisions, so each exercise can become part of your final portfolio architecture.