Testing UIs & Mocking

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

Unit tests are strongest when behavior lives in small functions. UI code adds another question: can a person actually find the control, type into it, click it, and see the result? Testing Library pushes your tests toward that question.

This lesson keeps the examples static because UI test setup depends on project packages. The ideas are the important part: query the page like a user, mock only at boundaries, control time with fake timers, and mock HTTP at the network layer.

Test the page a user sees

In a Vitest project, a UI test often runs in a simulated browser environment such as jsdom, then uses Testing Library helpers to find elements by labels, roles, and text.

npm install -D vitest jsdom @testing-library/dom @testing-library/user-event

The config points Vitest at a DOM-like test environment:

import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    environment: "jsdom",
  },
});

Here is a small framework-free view function:

export function renderSignupForm({ onSubmit }) {
  const form = document.createElement("form");
 
  form.innerHTML = `
    <label>
      Email
      <input name="email" type="email" />
    </label>
    <button type="submit">Join waitlist</button>
    <p role="status"></p>
  `;
 
  form.addEventListener("submit", (event) => {
    event.preventDefault();
 
    const data = new FormData(form);
    const email = String(data.get("email")).trim();
 
    onSubmit(email);
    form.querySelector("[role='status']").textContent = `Thanks, ${email}`;
  });
 
  document.body.append(form);
}

The test interacts with it through visible affordances:

import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderSignupForm } from "./signup-view.js";
 
afterEach(() => {
  document.body.innerHTML = "";
});
 
describe("renderSignupForm", () => {
  it("thanks the user after they submit an email", async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
 
    renderSignupForm({ onSubmit: handleSubmit });
 
    await user.type(screen.getByLabelText("Email"), "ada@example.com");
    await user.click(screen.getByRole("button", { name: "Join waitlist" }));
 
    expect(screen.getByRole("status").textContent).toBe("Thanks, ada@example.com");
    expect(handleSubmit).toHaveBeenCalledWith("ada@example.com");
  });
});

Notice what the test does not know: there is no query for a CSS class, no inspection of local variables, and no check that a particular private helper was called. It finds the email field by its label and the button by its role and name.

That principle is pro-contract. Your UI contract is what a user can perceive and do.

Mocks and spies

A mock is a fake replacement you control. A spy records how something was called. In Vitest, both usually come through the vi helper.

Use mocks for boundaries: analytics, notifications, storage adapters, and callbacks your UI is supposed to call.

export function trackSignup(analytics, email) {
  analytics.track("signup_submitted", { email });
}
import { describe, expect, it, vi } from "vitest";
import { trackSignup } from "./analytics.js";
 
describe("trackSignup", () => {
  it("records the submitted email with the analytics service", () => {
    const analytics = {
      track: vi.fn(),
    };
 
    trackSignup(analytics, "ada@example.com");
 
    expect(analytics.track).toHaveBeenCalledWith("signup_submitted", {
      email: "ada@example.com",
    });
  });
});

Use spies when the real object already exists and you want to observe one method:

import { afterEach, describe, expect, it, vi } from "vitest";
 
afterEach(() => {
  vi.restoreAllMocks();
});
 
describe("warnAboutDraft", () => {
  it("warns when a draft is missing a title", () => {
    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
 
    console.warn("Draft is missing a title");
 
    expect(warnSpy).toHaveBeenCalledWith("Draft is missing a title");
  });
});

The danger is over-mocking. If every helper is mocked, the test proves that your mocks agree with each other, not that the feature works. Mock slow, random, external, or hard-to-control boundaries. Let ordinary app code run.

Fake timers

Time makes tests slow and flaky when you wait for real seconds. Fake timers let a test control timer APIs directly.

export function showSavedMessage(setMessage) {
  setMessage("Saved");
  setTimeout(() => {
    setMessage("");
  }, 2000);
}
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { showSavedMessage } from "./saved-message.js";
 
beforeEach(() => {
  vi.useFakeTimers();
});
 
afterEach(() => {
  vi.useRealTimers();
});
 
describe("showSavedMessage", () => {
  it("clears the saved message after two seconds", () => {
    const setMessage = vi.fn();
 
    showSavedMessage(setMessage);
 
    expect(setMessage).toHaveBeenCalledWith("Saved");
 
    vi.advanceTimersByTime(2000);
 
    expect(setMessage).toHaveBeenLastCalledWith("");
  });
});

The test finishes immediately, but it still proves the two-second behavior. Always return to real timers so the next test does not inherit your fake clock.

Mock network requests with MSW

Network code is a special boundary. You could mock your own loadOrders function, but then the code that builds a real fetch request never runs. MSW lets tests keep the production request path and intercept HTTP at the request layer.

npm install -D msw

The app code stays ordinary:

const ORDERS_URL = "https://api.example.test/orders";
 
export async function loadOrders() {
  const response = await fetch(ORDERS_URL);
 
  if (!response.ok) {
    throw new Error("Could not load orders");
  }
 
  return response.json();
}

The test supplies controlled HTTP responses:

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { loadOrders } from "./orders-client.js";
 
const server = setupServer(
  http.get("https://api.example.test/orders", () => {
    return HttpResponse.json([{ id: "order-1", totalCents: 2499 }]);
  }),
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("loadOrders", () => {
  it("loads orders from the API", async () => {
    await expect(loadOrders()).resolves.toEqual([
      { id: "order-1", totalCents: 2499 },
    ]);
  });
 
  it("surfaces server failures", async () => {
    server.use(
      http.get("https://api.example.test/orders", () => {
        return new HttpResponse(null, { status: 500 });
      }),
    );
 
    await expect(loadOrders()).rejects.toThrow("Could not load orders");
  });
});

This keeps the contract honest: loadOrders still calls fetch, checks response.ok, and parses JSON. The test avoids the real internet without pretending the network is just another local function.

Pick the smallest honest fake

Mocks are tools, not a goal. A useful test asks, "What is the smallest piece I can fake while still exercising the behavior I care about?"

  • For a callback, use vi.fn().
  • For an existing method you must observe, use vi.spyOn(...) and restore it.
  • For timers, use fake timers and advance time deliberately.
  • For HTTP, prefer MSW so request-building and response-handling code still runs.
  • For full browser behavior across pages, wait for an end-to-end test.

Next, you will leave the simulated DOM and run tests in a real browser with Playwright, where the tradeoff shifts from cheap focused feedback to higher-confidence end-to-end flows.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion