Skip to content
Back to Quality Assurance

Unit Testing

Test the smallest piece of logic in isolation, fast and deterministic.

Why

Unit tests are the first line of defense against regressions. They run in milliseconds, execute thousands of times a day in CI, and pinpoint the exact function that broke when they fail. Without them, every refactor is a gamble — you change a shared utility and hope nothing downstream breaks, because the only feedback is a manual click through the app. Teams with strong unit coverage ship faster because they trust their tests to catch the obvious breakage, freeing them to focus manual testing on the subtle, exploratory paths that automation cannot reach.

When

Write the first unit test the moment you have a pure function or a class with logic. Add one per bug fix (regression guard) and one per new public method. Aim for 70-80% line coverage on business logic; skip trivial getters/setters.

How

1. Pick a framework that matches your stack
   - JS/TS: Vitest (fast, Vite-native) or Jest
   - Python: pytest with fixtures
   - Go: built-in testing package + testify
   - Rust: cargo test + #[test]

2. Structure each test in Arrange-Act-Assert
   - Arrange: set up inputs and dependencies (mocks/stubs)
   - Act: call the function under test, capture the result
   - Assert: verify the result matches expectation
   - One logical assertion per test; use descriptive names

3. Isolate external dependencies
   - Mock network calls, database, filesystem, time
   - Use dependency injection so real and fake can swap
   - Never mock the thing you are testing — mock its collaborators

4. Run them on every save (watch mode) and every CI push
   - vitest --watch / pytest-watch / cargo watch -x test
   - CI: fail the build on any red test, no "we'll fix it later"

5. Measure coverage but do not chase 100%
   - Generate a report, look for uncovered branches in business logic
   - Ignore coverage on entry points, config, and trivial code

Example

// src/lib/discount.ts
export function applyDiscount(price: number, code: string): number {
  if (code === "SAVE10") return round(price * 0.9);
  if (code === "SAVE20") return round(price * 0.8);
  return price;
}
const round = (n: number) => Math.round(n * 100) / 100;

// src/lib/__tests__/discount.test.ts
import { describe, it, expect } from "vitest";
import { applyDiscount } from "../discount";

describe("applyDiscount", () => {
  it("applies 10% off for SAVE10", () => {
    expect(applyDiscount(100, "SAVE10")).toBe(90);
  });
  it("applies 20% off for SAVE20", () => {
    expect(applyDiscount(100, "SAVE20")).toBe(80);
  });
  it("returns original price for unknown code", () => {
    expect(applyDiscount(100, "NOPE")).toBe(100);
  });
  it("rounds to 2 decimal places", () => {
    expect(applyDiscount(33.33, "SAVE10")).toBe(30);
  });
});

Common Problems

  • ⚠Tests that depend on execution order or shared mutable state pass alone but fail in a different sequence — always reset state in beforeEach, never rely on a previous test having run.
  • ⚠Mocking too deeply makes the test assert that the implementation called a specific method in a specific order, so any harmless refactor breaks the test even though behavior is unchanged.
  • ⚠Testing only the happy path leaves edge cases (null, empty array, negative numbers, concurrent calls) unguarded, and those are exactly where production bugs hide.
  • ⚠Slow unit tests (over 100ms each) usually mean a real database or network is being hit — the suite grows to minutes, devs stop running it locally, and regressions slip in.

Best Practices

  • ✓Name tests as sentences describing behavior: "returns 90 for SAVE10" reads as a spec; "test1" or "discountTest" tells you nothing when it fails in CI at 2am.
  • ✓Keep each test under 50ms; if it's slower, you are probably hitting I/O — extract the logic into a pure function and test that instead.
  • ✓Use table-driven tests for functions with many input-output pairs: one parameterized test with 20 rows is more maintainable than 20 copy-pasted test blocks.
  • ✓Treat test code with the same quality bar as production code — extract helpers, avoid duplication, and lint it; rotting test suites erode confidence in the whole suite.