Skip to main content
Software Testing · lesson 1 of 8
Software Testing beginner Lesson 1 of 8

Testing Strategy & Test Pyramid (Beginner)

Choose between unit, integration and end-to-end tests by speed and reliability, with Jest examples of a pure unit test and an injected, fixed clock.

By SkillByExample · Updated

Theory

A good testing strategy optimizes for:

  • confidence (catch bugs before release),
  • speed (fast feedback),
  • cost (time to maintain tests),
  • debuggability (tests tell you what broke).

The Test Pyramid (practical guideline)

  • Unit tests: fast, isolate logic, run often
  • Integration tests: verify interactions (DB, queues, APIs)
  • End-to-end (E2E): verify critical paths (UI/API flows)

Rule of thumb:

  • many unit tests
  • fewer integration tests
  • very few E2E tests

What “good” unit tests look like

  • Test one behavior per case
  • Avoid heavy setup/IO
  • Use deterministic inputs
  • Name tests by behavior: should_return_total_when_items_added

Keep integration tests stable

  • Use test containers / ephemeral DBs where possible
  • Seed deterministic data
  • Control time (clock abstraction)
  • Avoid external network dependencies

Code Example (TypeScript/Jest: a pure unit test)

The examples run under Jest with ts-jest. Put each file in one directory alongside a jest.config.js containing module.exports = { preset: "ts-jest", testEnvironment: "node" };.

// price.ts
export function applyDiscount(subtotal: number, percent: number) {
  return subtotal * (1 - percent / 100);
}
// price.test.ts
import { applyDiscount } from "./price";

test("should apply percent discount correctly", () => {
  expect(applyDiscount(200, 10)).toBe(180);
});
$ npx jest price.test.ts
PASS ./price.test.ts
  √ should apply percent discount correctly (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.008 s
Ran all test suites matching price.test.ts.

This is the base of the pyramid: no database, no network, no clock. It takes about a second, and nearly all of that is Jest and the TypeScript compiler starting up. The test itself took 2 ms. A suite of a thousand tests like this still finishes in seconds, which is why unit tests can be the bulk of the suite.

Code Example (TypeScript/Jest: a unit test with a deterministic clock)

Time is the most common thing that makes a unit test non-deterministic. Pass the clock in as a function, and the test can choose the time.

// greeting.ts
export function greeting(now: () => Date = () => new Date()) {
  const hour = now().getUTCHours();
  return hour < 12 ? "Good morning" : "Good afternoon";
}
// greeting.test.ts
import { greeting } from "./greeting";

test("real clock: depends on when you run it", () => {
  expect(greeting()).toBe("Good morning");
});

test("fixed clock at 09:00 UTC says good morning", () => {
  const clock = () => new Date("2026-01-01T09:00:00Z");
  expect(greeting(clock)).toBe("Good morning");
});

test("fixed clock at 15:00 UTC says good afternoon", () => {
  const clock = () => new Date("2026-01-01T15:00:00Z");
  expect(greeting(clock)).toBe("Good afternoon");
});
$ date -u
Sun, Oct 11, 2026 11:00:41 AM
$ npx jest greeting.test.ts
PASS ./greeting.test.ts
  √ real clock: depends on when you run it (1 ms)
  √ fixed clock at 09:00 UTC says good morning
  √ fixed clock at 15:00 UTC says good afternoon (1 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.395 s
Ran all test suites matching greeting.test.ts.

All three pass at 11:00 UTC. Here is the same file, unchanged, an hour later:

$ date -u
Sun, Oct 11, 2026 12:00:16 PM
$ npx jest greeting.test.ts
FAIL ./greeting.test.ts
  × real clock: depends on when you run it (4 ms)
  √ fixed clock at 09:00 UTC says good morning (1 ms)
  √ fixed clock at 15:00 UTC says good afternoon

  ● real clock: depends on when you run it

    expect(received).toBe(expected) // Object.is equality

    Expected: "Good morning"
    Received: "Good afternoon"

      2 |
      3 | test("real clock: depends on when you run it", () => {
    > 4 |   expect(greeting()).toBe("Good morning");
        |                      ^
      5 | });
      6 |
      7 | test("fixed clock at 09:00 UTC says good morning", () => {

      at Object.<anonymous> (greeting.test.ts:4:22)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        1.072 s, estimated 2 s
Ran all test suites matching greeting.test.ts.

Nothing in the code changed. Only the time did. The first test reads the real clock, so it passes in the morning and fails in the afternoon. The other two pin the clock, so they give the same answer at any hour. Reading getUTCHours() rather than getHours() matters too, because getHours() uses the machine’s time zone, and a CI runner set to UTC would disagree with a laptop set to another zone.

Practice

  1. Test the edges of the discount. Add tests for a 0% and a 100% discount on a subtotal of 200. Predict both results before you run them.
  2. A fractional price. Assert applyDiscount(49.99, 20) with toBe(39.992). Run it, explain the failure, then fix the assertion without changing price.ts.
  3. Noon exactly. Which greeting does greeting return at exactly 12:00:00Z, and at 11:59:59.999Z? Write a fixed-clock test for each.
  4. Add an evening. Change greeting to return "Good evening" from 18:00 UTC. Cover all three branches with a single test.each table of fixed clocks.
Solution 1
// price-edges.test.ts
import { applyDiscount } from "./price";

test("0% discount leaves the subtotal unchanged", () => {
  expect(applyDiscount(200, 0)).toBe(200);
});

test("100% discount makes it free", () => {
  expect(applyDiscount(200, 100)).toBe(0);
});
$ npx jest price-edges.test.ts
PASS ./price-edges.test.ts
  √ 0% discount leaves the subtotal unchanged (2 ms)
  √ 100% discount makes it free

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.173 s, estimated 3 s
Ran all test suites matching price-edges.test.ts.

Both pass. 0 and 100 are the boundary values of the percent parameter. A bug in the formula, such as percent / 10, would show up here first.

Solution 2
// price-cents.test.ts
import { applyDiscount } from "./price";

test("toBe on a fractional price", () => {
  expect(applyDiscount(49.99, 20)).toBe(39.992);
});

test("toBeCloseTo on a fractional price", () => {
  expect(applyDiscount(49.99, 20)).toBeCloseTo(39.992, 6);
});
$ npx jest price-cents.test.ts
FAIL ./price-cents.test.ts
  × toBe on a fractional price (4 ms)
  √ toBeCloseTo on a fractional price

  ● toBe on a fractional price

    expect(received).toBe(expected) // Object.is equality

    Expected: 39.992
    Received: 39.992000000000004

      2 |
      3 | test("toBe on a fractional price", () => {
    > 4 |   expect(applyDiscount(49.99, 20)).toBe(39.992);
        |                                    ^
      5 | });
      6 |
      7 | test("toBeCloseTo on a fractional price", () => {

      at Object.<anonymous> (price-cents.test.ts:4:36)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        2 s, estimated 3 s
Ran all test suites matching price-cents.test.ts.

49.99 has no exact binary representation, so the product comes out as 39.992000000000004. toBe uses Object.is, so a difference in the last digit fails it. toBeCloseTo(39.992, 6) checks agreement to 6 decimal places and passes. The 200 * 0.9 case in the lesson passed with toBe only because that product happens to round to exactly 180. For real money, store integer cents instead of fractional amounts.

Solution 3
// greeting-noon.test.ts
import { greeting } from "./greeting";

test("exactly 12:00 UTC", () => {
  const clock = () => new Date("2026-01-01T12:00:00Z");
  expect(greeting(clock)).toBe("Good afternoon");
});

test("one millisecond before noon", () => {
  const clock = () => new Date("2026-01-01T11:59:59.999Z");
  expect(greeting(clock)).toBe("Good morning");
});
$ npx jest greeting-noon.test.ts
PASS ./greeting-noon.test.ts
  √ exactly 12:00 UTC (2 ms)
  √ one millisecond before noon (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.282 s, estimated 3 s
Ran all test suites matching greeting-noon.test.ts.

hour < 12 is false at hour 12, so noon counts as afternoon. With a real clock you could never pin a test to the last millisecond of the morning. With an injected clock it is a one-line test.

Solution 4
// greeting.ts
export function greeting(now: () => Date = () => new Date()) {
  const hour = now().getUTCHours();
  if (hour < 12) return "Good morning";
  if (hour < 18) return "Good afternoon";
  return "Good evening";
}
// greeting.test.ts
import { greeting } from "./greeting";

const at = (iso: string) => () => new Date(iso);

test.each([
  ["2026-01-01T09:00:00Z", "Good morning"],
  ["2026-01-01T17:59:59Z", "Good afternoon"],
  ["2026-01-01T18:00:00Z", "Good evening"],
])("at %s says %s", (iso, expected) => {
  expect(greeting(at(iso))).toBe(expected);
});
$ npx jest greeting.test.ts
PASS ./greeting.test.ts
  √ at 2026-01-01T09:00:00Z says Good morning (2 ms)
  √ at 2026-01-01T17:59:59Z says Good afternoon
  √ at 2026-01-01T18:00:00Z says Good evening

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        1.781 s
Ran all test suites matching greeting.test.ts.

Each row is one fixed clock, and %s puts the row’s values into the test name, so a failure report says exactly which time broke. The 17:59:59 and 18:00:00 rows sit on either side of the new boundary.

Common pitfalls

  • Only writing E2E tests (slow + flaky)
  • Snapshot tests without meaningful assertions
  • Tests that depend on time/randomness without control
  • Brittle mocks that break during refactors

Frequently Asked Questions

Should I always aim for 80% unit tests?
Not blindly. Aim for the pyramid that matches your risk: more unit tests for logic, more integration tests for boundaries, and fewer E2E tests for critical user flows.
Why do end-to-end tests fail frequently?
They touch more systems, such as the network, real time and external dependencies, and each one can fail independently of your code. Keep E2E tests few, keep them on the critical user flows, and control what you can, such as test data and clocks.
How do I unit test code that uses the current time?
Pass the clock in as a parameter, for example a function that returns a Date, and default it to the real clock in production. The test then passes a function that returns a fixed time, so the result is the same whenever and wherever the test runs.
Why does toBe fail on a decimal result in Jest?
toBe compares with Object.is, so 39.992 and 39.992000000000004 are different values. Floating-point arithmetic often lands a tiny distance from the decimal you expect. Use toBeCloseTo for fractional numbers, or store money as integer cents so exact comparison works.