Learn

/

Unit Testing

Unit Testing

8 patterns

Test structure, assertions, naming, arrange-act-assert, and isolation. You'll hit this when tests are hard to read, break on refactors, or test implementation details instead of behavior.

Avoid
describe("calculateTotal", () => {
  it("test1", () => {
    expect(calculateTotal([10, 20])).toBe(30);
  });

  it("test2", () => {
    expect(calculateTotal([])).toBe(0);
  });

  it("test3", () => {
    expect(calculateTotal([-5, 5])).toBe(0);
  });
});
describe("calculateTotal", () => {
  it("test1", () => {
    expect(calculateTotal([10, 20])).toBe(30);
  });

  it("test2", () => {
    expect(calculateTotal([])).toBe(0);
  });

  it("test3", () => {
    expect(calculateTotal([-5, 5])).toBe(0);
  });
});

Prefer
describe("calculateTotal", () => {
  it("returns the sum of all items", () => {
    expect(calculateTotal([10, 20])).toBe(30);
  });

  it("returns zero for an empty array", () => {
    expect(calculateTotal([])).toBe(0);
  });

  it("handles negative numbers", () => {
    expect(calculateTotal([-5, 5])).toBe(0);
  });
});
describe("calculateTotal", () => {
  it("returns the sum of all items", () => {
    expect(calculateTotal([10, 20])).toBe(30);
  });

  it("returns zero for an empty array", () => {
    expect(calculateTotal([])).toBe(0);
  });

  it("handles negative numbers", () => {
    expect(calculateTotal([-5, 5])).toBe(0);
  });
});
Why avoid

Cryptic names like 'test1' and 'test2' force developers to read the full test body to understand what is being verified. As a test suite grows, this makes failures hard to triage and the suite difficult to maintain.

Why prefer

Descriptive test names serve as living documentation. When a test fails, a name like 'returns zero for an empty array' immediately tells you what broke, without needing to read the test body. This speeds up debugging and helps teammates understand intent.

Vitest: Writing Tests
Avoid
it("applies discount to order total", () => {
  const result = applyDiscount(
    createOrder({ subtotal: 100 }),
    createCoupon({ percent: 20 }),
  );
  expect(result.total).toBe(80);
  expect(result.discount).toBe(20);
  const order2 = createOrder({ subtotal: 50 });
  expect(applyDiscount(order2, createCoupon({ percent: 10 })).total).toBe(45);
});
it("applies discount to order total", () => {
  const result = applyDiscount(
    createOrder({ subtotal: 100 }),
    createCoupon({ percent: 20 }),
  );
  expect(result.total).toBe(80);
  expect(result.discount).toBe(20);
  const order2 = createOrder({ subtotal: 50 });
  expect(applyDiscount(order2, createCoupon({ percent: 10 })).total).toBe(45);
});

Prefer
it("applies discount to order total", () => {
  // Arrange
  const order = createOrder({ subtotal: 100 });
  const coupon = createCoupon({ percent: 20 });

  // Act
  const result = applyDiscount(order, coupon);

  // Assert
  expect(result.total).toBe(80);
  expect(result.discount).toBe(20);
});
it("applies discount to order total", () => {
  // Arrange
  const order = createOrder({ subtotal: 100 });
  const coupon = createCoupon({ percent: 20 });

  // Act
  const result = applyDiscount(order, coupon);

  // Assert
  expect(result.total).toBe(80);
  expect(result.discount).toBe(20);
});
Why avoid

Mixing setup, execution, and assertions into a single block makes it hard to tell where one logical step ends and another begins. Cramming multiple scenarios into one test also means a failure message points to the wrong cause.

Why prefer

The Arrange-Act-Assert pattern gives each test a clear three-part rhythm: set up inputs, execute the code under test, then verify the outcome. This makes tests easy to scan and predictable in structure, even as the suite grows.

Testing Library: Guiding Principles
Avoid
it("sorts products by price ascending", () => {
  const spy = vi.spyOn(Array.prototype, "sort");
  const products = [
    { name: "Shirt", price: 30 },
    { name: "Hat", price: 10 },
    { name: "Jacket", price: 50 },
  ];

  sortProducts(products, "price-asc");

  expect(spy).toHaveBeenCalledOnce();
  expect(spy).toHaveBeenCalledWith(expect.any(Function));
  spy.mockRestore();
});
it("sorts products by price ascending", () => {
  const spy = vi.spyOn(Array.prototype, "sort");
  const products = [
    { name: "Shirt", price: 30 },
    { name: "Hat", price: 10 },
    { name: "Jacket", price: 50 },
  ];

  sortProducts(products, "price-asc");

  expect(spy).toHaveBeenCalledOnce();
  expect(spy).toHaveBeenCalledWith(expect.any(Function));
  spy.mockRestore();
});

Prefer
it("sorts products by price ascending", () => {
  const products = [
    { name: "Shirt", price: 30 },
    { name: "Hat", price: 10 },
    { name: "Jacket", price: 50 },
  ];

  const sorted = sortProducts(products, "price-asc");

  expect(sorted[0].price).toBe(10);
  expect(sorted[1].price).toBe(30);
  expect(sorted[2].price).toBe(50);
});
it("sorts products by price ascending", () => {
  const products = [
    { name: "Shirt", price: 30 },
    { name: "Hat", price: 10 },
    { name: "Jacket", price: 50 },
  ];

  const sorted = sortProducts(products, "price-asc");

  expect(sorted[0].price).toBe(10);
  expect(sorted[1].price).toBe(30);
  expect(sorted[2].price).toBe(50);
});
Why avoid

Asserting that Array.prototype.sort was called checks how the function works, not what it produces. The test would break if the implementation switched to a different sorting approach, even though the output remained identical.

Why prefer

Testing the observable output (the sorted order) confirms that the function does what users care about. If the internal sorting algorithm changes, the test still passes as long as the result is correct, making refactoring safe.

Testing Library: Guiding Principles
Avoid
it("formats a date, validates email, and parses CSV", () => {
  expect(formatDate(new Date(2024, 0, 1))).toBe("2024-01-01");

  expect(isValidEmail("user@example.com")).toBe(true);

  const rows = parseCsv("a,b\n1,2");
  expect(rows).toEqual([{ a: "1", b: "2" }]);
});
it("formats a date, validates email, and parses CSV", () => {
  expect(formatDate(new Date(2024, 0, 1))).toBe("2024-01-01");

  expect(isValidEmail("user@example.com")).toBe(true);

  const rows = parseCsv("a,b\n1,2");
  expect(rows).toEqual([{ a: "1", b: "2" }]);
});

Prefer
it("formats a Date into an ISO date string", () => {
  const result = formatDate(new Date(2024, 0, 1));
  expect(result).toBe("2024-01-01");
});

it("accepts a valid email address", () => {
  expect(isValidEmail("user@example.com")).toBe(true);
});

it("parses CSV rows into objects", () => {
  const rows = parseCsv("a,b\n1,2");
  expect(rows).toEqual([{ a: "1", b: "2" }]);
});
it("formats a Date into an ISO date string", () => {
  const result = formatDate(new Date(2024, 0, 1));
  expect(result).toBe("2024-01-01");
});

it("accepts a valid email address", () => {
  expect(isValidEmail("user@example.com")).toBe(true);
});

it("parses CSV rows into objects", () => {
  const rows = parseCsv("a,b\n1,2");
  expect(rows).toEqual([{ a: "1", b: "2" }]);
});
Why avoid

Bundling unrelated assertions into one test means a failure in formatDate hides whether isValidEmail or parseCsv also failed. It also makes the test name meaningless since no single name can describe three unrelated checks.

Why prefer

Each test focuses on one behavior, so a failure pinpoints exactly which function broke. Isolated tests also run independently, making it straightforward to rerun or skip a single case during development.

Vitest: Writing Tests
Avoid
it("grants edit access to editors", () => {
  const user = {
    id: "u-1",
    name: "Alice",
    email: "alice@test.com",
    role: "editor",
  };
  expect(canEdit(user)).toBe(true);
});

it("denies edit access to viewers", () => {
  const user = {
    id: "u-2",
    name: "Bob",
    email: "bob@test.com",
    role: "viewer",
  };
  expect(canEdit(user)).toBe(false);
});
it("grants edit access to editors", () => {
  const user = {
    id: "u-1",
    name: "Alice",
    email: "alice@test.com",
    role: "editor",
  };
  expect(canEdit(user)).toBe(true);
});

it("denies edit access to viewers", () => {
  const user = {
    id: "u-2",
    name: "Bob",
    email: "bob@test.com",
    role: "viewer",
  };
  expect(canEdit(user)).toBe(false);
});

Prefer
function buildUser(overrides = {}) {
  return {
    id: "u-1",
    name: "Alice",
    email: "alice@test.com",
    role: "viewer",
    ...overrides,
  };
}

it("grants edit access to editors", () => {
  const user = buildUser({ role: "editor" });
  expect(canEdit(user)).toBe(true);
});
function buildUser(overrides = {}) {
  return {
    id: "u-1",
    name: "Alice",
    email: "alice@test.com",
    role: "viewer",
    ...overrides,
  };
}

it("grants edit access to editors", () => {
  const user = buildUser({ role: "editor" });
  expect(canEdit(user)).toBe(true);
});
Why avoid

Repeating full object literals in every test adds boilerplate and buries the important field among defaults. When the User type gains a new required property, every test that constructs a user inline must be updated.

Why prefer

A builder function provides sensible defaults and lets each test override only the fields that matter. This highlights what the test actually cares about (the role) and reduces noise from irrelevant properties.

Jest: Repeating Setup
Avoid
describe("clamp", () => {
  it("returns the value when inside range", () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });
});
describe("clamp", () => {
  it("returns the value when inside range", () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });
});

Prefer
describe("clamp", () => {
  it("returns min when value is below range", () => {
    expect(clamp(-1, 0, 10)).toBe(0);
  });

  it("returns max when value is above range", () => {
    expect(clamp(11, 0, 10)).toBe(10);
  });

  it("returns min when value equals min", () => {
    expect(clamp(0, 0, 10)).toBe(0);
  });

  it("returns max when value equals max", () => {
    expect(clamp(10, 0, 10)).toBe(10);
  });

  it("returns the value when inside range", () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });
});
describe("clamp", () => {
  it("returns min when value is below range", () => {
    expect(clamp(-1, 0, 10)).toBe(0);
  });

  it("returns max when value is above range", () => {
    expect(clamp(11, 0, 10)).toBe(10);
  });

  it("returns min when value equals min", () => {
    expect(clamp(0, 0, 10)).toBe(0);
  });

  it("returns max when value equals max", () => {
    expect(clamp(10, 0, 10)).toBe(10);
  });

  it("returns the value when inside range", () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });
});
Why avoid

A single test with a value in the middle of the range only proves the function works for one easy case. It misses bugs at the boundaries, such as using < instead of <= or forgetting to clamp values below the minimum.

Why prefer

Testing at and beyond both boundaries catches off-by-one errors and incorrect comparison operators. Boundary values are where most bugs hide, so explicitly covering them provides much stronger confidence than a single happy-path check.

Jest: Matchers
Avoid
it("rejects empty string", () => {
  expect(isEmail("")).toBe(false);
});
it("rejects missing @", () => {
  expect(isEmail("abc")).toBe(false);
});
it("rejects missing domain", () => {
  expect(isEmail("abc@")).toBe(false);
});
it("accepts minimal email", () => {
  expect(isEmail("a@b.c")).toBe(true);
});
it("accepts standard email", () => {
  expect(isEmail("user@x.co")).toBe(true);
});
it("rejects empty string", () => {
  expect(isEmail("")).toBe(false);
});
it("rejects missing @", () => {
  expect(isEmail("abc")).toBe(false);
});
it("rejects missing domain", () => {
  expect(isEmail("abc@")).toBe(false);
});
it("accepts minimal email", () => {
  expect(isEmail("a@b.c")).toBe(true);
});
it("accepts standard email", () => {
  expect(isEmail("user@x.co")).toBe(true);
});

Prefer
it.each([
  { input: "",         expected: false },
  { input: "abc",      expected: false },
  { input: "abc@",     expected: false },
  { input: "a@b.c",    expected: true  },
  { input: "user@x.co", expected: true },
])("isEmail($input) returns $expected", ({ input, expected }) => {
  expect(isEmail(input)).toBe(expected);
});
it.each([
  { input: "",         expected: false },
  { input: "abc",      expected: false },
  { input: "abc@",     expected: false },
  { input: "a@b.c",    expected: true  },
  { input: "user@x.co", expected: true },
])("isEmail($input) returns $expected", ({ input, expected }) => {
  expect(isEmail(input)).toBe(expected);
});
Why avoid

Writing a separate test for each case duplicates the same assertion structure over and over. With five or more cases the file becomes long and repetitive, and adding a new scenario requires copying boilerplate instead of appending a row.

Why prefer

Parameterized tests (it.each) express many input/output pairs in a compact table. Adding a new case is a single line, and the pattern makes it obvious that every row follows the same logic. This reduces duplication and keeps the suite easy to extend.

Vitest: test.each
Avoid
// Calculate shipping cost
let config;

beforeEach(() => {
  config = { domesticRate: 5, internationalRate: 15 };
  global.__shippingConfig = config;
});

afterEach(() => {
  delete global.__shippingConfig;
});

it("calculates domestic shipping", () => {
  const result = getShippingCost(2, "domestic");
  expect(result).toBe(10);
});

it("calculates international shipping", () => {
  const result = getShippingCost(3, "international");
  expect(result).toBe(45);
});
// Calculate shipping cost
let config;

beforeEach(() => {
  config = { domesticRate: 5, internationalRate: 15 };
  global.__shippingConfig = config;
});

afterEach(() => {
  delete global.__shippingConfig;
});

it("calculates domestic shipping", () => {
  const result = getShippingCost(2, "domestic");
  expect(result).toBe(10);
});

it("calculates international shipping", () => {
  const result = getShippingCost(3, "international");
  expect(result).toBe(45);
});

Prefer
// Calculate shipping cost
function shippingCost(weightKg, zone) {
  const rate = zone === "domestic" ? 5 : 15;
  return weightKg * rate;
}

it("calculates domestic shipping", () => {
  expect(shippingCost(2, "domestic")).toBe(10);
});

it("calculates international shipping", () => {
  expect(shippingCost(3, "international")).toBe(45);
});
// Calculate shipping cost
function shippingCost(weightKg, zone) {
  const rate = zone === "domestic" ? 5 : 15;
  return weightKg * rate;
}

it("calculates domestic shipping", () => {
  expect(shippingCost(2, "domestic")).toBe(10);
});

it("calculates international shipping", () => {
  expect(shippingCost(3, "international")).toBe(45);
});
Why avoid

Relying on global state means every test must set up and clean up that state correctly. If a test forgets teardown or runs out of order, it can leak configuration into other tests, causing flaky failures that are difficult to reproduce.

Why prefer

Pure functions take all inputs as arguments and return a result with no hidden dependencies. Tests for pure functions need no setup or teardown, run in any order, and never interfere with each other, making the suite fast and reliable.

Vitest: Common Errors