JavaScript Testing — Vitest, Playwright, and Modern Testing Patterns
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
Write tests for a function that parses CSV strings.
Mock an API client and test error handling, retries, and timeout.
Write a Playwright test that verifies a search form works end-to-end.
Use snapshot testing for a React component that renders user profiles.
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