Skip to content

JavaScript Testing — Vitest, Playwright, and Modern Testing Patterns

DodaTech Updated 2026-06-29 6 min read

In this tutorial, you will learn about JavaScript Testing. We cover key concepts, practical examples, and best practices to help you master this topic.

Testing is not optional — it's how you ensure your code works correctly today and doesn't break tomorrow. The JavaScript ecosystem has excellent testing tools: Vitest for fast unit/integration tests, and Playwright for browser-based E2E testing.

DodaTech runs thousands of tests across its platform: unit tests for scan logic, integration tests for API endpoints, and E2E tests for the dashboard.

What You'll Learn

  • Setting up Vitest
  • Writing unit tests (describe, it, expect)
  • Mocking with vi
  • Snapshot testing
  • DOM testing with jsdom
  • E2E testing with Playwright
  • TDD workflow

Vitest Setup

npm install -D vitest @vitest/ui
// vitest.config.js (or vitest.config.ts)
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",   // For DOM testing
    globals: true,          // describe/it/expect globally
    coverage: {
      provider: "v8",
      reporter: ["text", "html"]
    }
  }
});
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

Unit Tests

// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}

// math.test.js
import { describe, it, expect } from "vitest";
import { add, divide } from "./math";

describe("add", () => {
  it("adds positive numbers", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("adds negative numbers", () => {
    expect(add(-1, -1)).toBe(-2);
  });

  it("handles zero", () => {
    expect(add(0, 5)).toBe(5);
    expect(add(0, 0)).toBe(0);
  });
});

describe("divide", () => {
  it("divides correctly", () => {
    expect(divide(10, 2)).toBe(5);
    expect(divide(7, 2)).toBe(3.5);
  });

  it("throws on division by zero", () => {
    expect(() => divide(1, 0)).toThrow("Division by zero");
  });
});

Matchers

// Common matchers
expect(value).toBe(42);                // ===
expect(value).toEqual({ a: 1 });       // deep equality
expect(value).toBeNull();              // null
expect(value).toBeDefined();           // not undefined
expect(value).toBeTruthy();            // truthy
expect(value).toBeFalsy();             // falsy

// Numbers
expect(value).toBeGreaterThan(10);
expect(value).toBeGreaterThanOrEqual(10);
expect(value).toBeLessThan(20);
expect(value).toBeCloseTo(3.14, 2);    // Floating point

// Strings
expect(str).toMatch(/hello/);
expect(str).toContain("world");

// Arrays and objects
expect(arr).toContain(5);
expect(arr).toHaveLength(3);
expect(obj).toHaveProperty("name");

// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("specific message");

// Not
expect(value).not.toBe(0);

Mocking

// api.js
export async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error("Failed to fetch");
  return response.json();
}

// user.js
import { fetchUser } from "./api";

export async function getUserDisplayName(id) {
  const user = await fetchUser(id);
  return `${user.firstName} ${user.lastName}`;
}

// user.test.js
import { describe, it, expect, vi } from "vitest";
import { getUserDisplayName } from "./user";

vi.mock("./api", () => ({
  fetchUser: vi.fn()
}));

describe("getUserDisplayName", () => {
  it("returns formatted name", async () => {
    const { fetchUser } = await import("./api");
    fetchUser.mockResolvedValue({
      firstName: "Alice",
      lastName: "Smith"
    });

    const name = await getUserDisplayName(42);
    expect(name).toBe("Alice Smith");
    expect(fetchUser).toHaveBeenCalledWith(42);
  });

  it("handles errors", async () => {
    const { fetchUser } = await import("./api");
    fetchUser.mockRejectedValue(new Error("Network error"));

    await expect(getUserDisplayName(1)).rejects.toThrow("Network error");
  });
});

Timers Mocking

// timer.js
export function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

// timer.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

describe("debounce", () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it("calls function after delay", () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    expect(fn).not.toHaveBeenCalled();

    vi.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
  });

  it("debounces multiple calls", () => {
    const fn = vi.fn();
    const debounced = debounce(fn, 300);

    debounced();
    debounced();
    debounced();

    vi.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

Snapshot Testing

// render.js
export function renderStatus(status) {
  const colors = { success: "green", error: "red", pending: "yellow" };
  return `<span style="color: ${colors[status]}">${status}</span>`;
}

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

describe("renderStatus", () => {
  it("renders success", () => {
    expect(renderStatus("success")).toMatchSnapshot();
  });

  it("renders all statuses consistently", () => {
    for (const status of ["success", "error", "pending"]) {
      expect(renderStatus(status)).toMatchSnapshot(status);
    }
  });
});

DOM Testing with jsdom

// form.js
export function validateForm(formData) {
  const errors = {};
  if (!formData.get("email")?.includes("@")) {
    errors.email = "Invalid email";
  }
  if ((formData.get("password") || "").length < 8) {
    errors.password = "Password too short";
  }
  return Object.keys(errors).length > 0 ? errors : null;
}

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

describe("validateForm", () => {
  it("accepts valid form", () => {
    const data = new FormData();
    data.set("email", "a@b.com");
    data.set("password", "supersecret");
    expect(validateForm(data)).toBeNull();
  });

  it("rejects invalid email", () => {
    const data = new FormData();
    data.set("email", "invalid");
    data.set("password", "supersecret");
    expect(validateForm(data)).toEqual({
      email: expect.stringContaining("email")
    });
  });
});

E2E Testing with Playwright

npm install -D @playwright/test
npx playwright install
// playwright.config.js
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  use: {
    baseURL: "http://localhost:5173",
  },
});
// e2e/login.spec.js
import { test, expect } from "@playwright/test";

test("shows login form", async ({ page }) => {
  await page.goto("/login");

  // Check heading
  await expect(
    page.getByRole("heading", { name: "Sign In" })
  ).toBeVisible();

  // Fill and submit
  await page.fill("#email", "user@example.com");
  await page.fill("#password", "secret123");
  await page.click("button[type=submit]");

  // Wait for redirect to dashboard
  await expect(page).toHaveURL(/\/dashboard/);
});

test("shows validation errors", async ({ page }) => {
  await page.goto("/login");
  await page.click("button[type=submit]");

  await expect(page.getByText("Email is required")).toBeVisible();
  await expect(page.getByText("Password is required")).toBeVisible();
});

TDD Workflow Example

// Step 1: Write the test FIRST
describe("calculateScore", () => {
  it("returns 0 for empty array", () => {
    expect(calculateScore([])).toBe(0);
  });
  it("sums positive numbers", () => {
    expect(calculateScore([1, 2, 3])).toBe(6);
  });
  it("subtracts penalties for negative values", () => {
    expect(calculateScore([10, -5, 20])).toBe(20); // 10 - 5*2 + 20
  });
  it("caps at 100", () => {
    expect(calculateScore([100, 100])).toBe(100);
  });
});

// Step 2: Implement to pass
export function calculateScore(values) {
  if (values.length === 0) return 0;
  let total = 0;
  for (const v of values) {
    total += v >= 0 ? v : v * 2; // Penalty: double negative
  }
  return Math.min(total, 100);
}

Practice Questions

  1. Write tests for a function that parses CSV strings.

  2. Mock an API client and test error handling, retries, and timeout.

  3. Write a Playwright test that verifies a search form works end-to-end.

  4. Use snapshot testing for a React component that renders user profiles.

  5. Implement a test suite for a rate limiter class.

Challenge: Test Coverage for Async Retry

Write tests (first, TDD-style) for a function:

async function fetchWithRetry(url, options = {}) {
  const { retries = 3, backoff = 1000 } = options;
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await fetch(url);
    } catch (err) {
      if (attempt === retries) throw err;
      await new Promise(r => setTimeout(r, backoff * attempt));
    }
  }
}

Test these scenarios:

  • Succeeds on first attempt
  • Succeeds after retry (first two attempts fail)
  • Fails after all retries exhausted
  • Uses exponential backoff timing
  • Passes through non-network errors

Real-World Task: API Integration Test Suite

Write integration tests for a REST API:

  • Test CRUD operations for each resource
  • Test authentication (valid token, expired token, no token)
  • Test pagination: page size, cursor, total count
  • Test Rate Limiting: verify 429 after too many requests
  • Test idempotency: same request twice returns same result
  • Validate response schemas

This is the test suite pattern DodaTech uses for all its API endpoints — ensuring security scans, report generation, and Configuration Management work correctly across versions.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro