Software Testing · lesson 1 of 8
Beginner
1Intermediate
4Advanced
3Also part of Software Testing
12- + Testing Python Code from Python
- + Unit Testing with JUnit 5 and Mockito from Java
- + Testing JavaScript with Jest and Vitest from JavaScript
- + Testing in Go from Go
- + Testing in Rust from Rust
- + Testing with MUnit and ScalaCheck from Scala
- + Contract Testing with OpenAPI, and Running in CI from API Testing
- + Your First Playwright Test from Playwright
- + Flaky Tests: Staleness, Retries, and CI from Selenium
- + Testing Data Pipelines from Data Engineering
- + terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan from Terraform
- + Testing TypeScript from TypeScript
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
- 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. - A fractional price. Assert
applyDiscount(49.99, 20)withtoBe(39.992). Run it, explain the failure, then fix the assertion without changingprice.ts. - Noon exactly. Which greeting does
greetingreturn at exactly12:00:00Z, and at11:59:59.999Z? Write a fixed-clock test for each. - Add an evening. Change
greetingto return"Good evening"from 18:00 UTC. Cover all three branches with a singletest.eachtable 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