← Knowledge Base

Testing

A comprehensive, practical guide to software testing strategies — from philosophy to production. Write tests that earn their keep.

1 Why We Test

Software testing is not a tax on development — it is an investment that pays compound interest. Every test you write is a bet that the thing it covers will break some day, and the cost of catching that breakage in your test suite is orders of magnitude cheaper than catching it in production.

The Cost of Bugs: Shift-Left Economics

The further a bug travels from the moment it was introduced, the more expensive it becomes to fix. This principle is the economic foundation of the entire testing discipline.

Phase Relative Cost Example ─────────────────────────────────────────────────────── Design 1x Catch on whiteboard Coding 5x Catch in code review Unit Test 10x Catch in test run Integration 25x Catch in CI pipeline QA / Staging 50x Catch in manual testing Production 100x+ Catch via customer report Post-release 500x+ Recall, lawsuit, breach

Shifting testing left — running it as early as possible — captures defects when they are cheapest to fix. A unit test that catches a null-pointer bug costs a few seconds to run. The same bug discovered by a customer on a Friday evening costs incident response time, reputation damage, and possibly revenue.

Confidence to Deploy

A robust test suite is the difference between deploying with confidence on a Tuesday afternoon and dreading every release. Without tests, deploying is an act of hope. With tests, it is a measured decision backed by evidence.

🚀

Continuous Delivery Requires Continuous Confidence

If your team cannot deploy on any given day without anxiety, your test suite is not doing its job. Tests are the safety net that enables velocity.

Documentation Through Tests

Good tests are living documentation. Unlike comments and wiki pages, tests are verified every time they run. If the behavior changes and the documentation does not, the test fails — forcing the docs to stay in sync with reality.

// This test IS the documentation for transfer behavior:
it('should reject transfer when sender has insufficient funds', () => {
    const sender = createAccount({ balance: 100 });
    const receiver = createAccount({ balance: 50 });

    const result = transfer(sender, receiver, 200);

    expect(result.success).toBe(false);
    expect(result.error).toBe('INSUFFICIENT_FUNDS');
    expect(sender.balance).toBe(100);   // unchanged
    expect(receiver.balance).toBe(50);  // unchanged
});

Regression Prevention

Every bug fix should come with a test that reproduces the bug. This test becomes a permanent guard against regression. Over time, your test suite accumulates a history of every mistake the team has made — and ensures none of them happen twice.

The Real ROI of Testing

💰 Direct Savings

Fewer production incidents, reduced on-call burden, less time spent in post-mortem meetings. Teams with strong test suites typically spend 20-40% less time on bug-related work.

⚡ Velocity Gains

Developers refactor with confidence, onboard faster by reading tests, and merge with less fear. The compounding effect on team speed is dramatic over months.

💪 Code Quality

Writing testable code forces better design: smaller functions, clearer interfaces, explicit dependencies. Testing is a design pressure as much as a quality tool.

"If you don't like testing your product, most likely your customers won't like to test it either." — Anonymous

2 The Test Pyramid

Mike Cohn's test pyramid is the foundational mental model for balancing test types. It prescribes a distribution: many fast, cheap unit tests at the base; fewer integration tests in the middle; and very few slow, expensive end-to-end tests at the top.

/---------\ / E2E Tests \ Few, slow, expensive / (UI, full \ / system) \ /---------------------\ / Integration Tests \ Some, medium speed / (API, DB, services) \ /-----------------------------\ / Unit Tests \ Many, fast, cheap / (functions, classes, modules) \ /-------------------------------------\

Why the Pyramid Shape?

LayerSpeedCostReliabilityFeedbackQuantity
Unit~msLowHighInstantThousands
Integration~secondsMediumMediumMinutesHundreds
E2E~minutesHighLow (flaky)SlowTens

The Inverted Pyramid Anti-Pattern

Many teams end up with an inverted pyramid — heavy on E2E tests, light on unit tests. This creates a test suite that is slow, flaky, expensive to maintain, and gives late feedback. Teams with this shape often abandon automated testing entirely out of frustration.

\-------------------------------------/ \ E2E Tests (too many!) / \-----------------------------/ \ Integration Tests / \-----------------------/ \ Unit Tests (too / \ few!) / \-----------/ "The ice cream cone" — expensive at top, melty and unreliable

The Testing Trophy (Kent C. Dodds)

Kent C. Dodds proposed an alternative shape for frontend-heavy applications: the testing trophy. It emphasizes integration tests as the sweet spot — they give the most confidence per dollar spent.

.--. | E2E| Few: critical paths only '--' .----------. | Integration| MOST: components working together '----------' .------. | Unit | Some: pure logic, utilities '------' .------------. | Static Types | Base: TypeScript, ESLint, etc. '------------'

The Testing Diamond

Common in microservices and backend systems, the diamond shape has a bulging middle of integration/API tests, fewer units (because logic is simpler), and fewer E2E tests (because they cross service boundaries and are costly).

/---------\ / E2E \ /---------------\ / \ / Integration Tests \ Widest: API, DB, messaging / \ \-----------------------/ \ Unit Tests / \-----------------/

Which Shape Fits Your Project?

▵ Pyramid

Best for: Libraries, frameworks, systems with complex domain logic. Lots of pure functions and business rules to unit-test.

🏆 Trophy

Best for: Frontend apps, React/Vue/Angular projects. Integration tests (render component + simulate interaction) give the best ROI.

◆ Diamond

Best for: Microservices, APIs, data pipelines. Integration with databases and HTTP boundaries matters most.

3 What to Test (and What Not To)

Test Behavior, Not Implementation

Tests should assert what the code does, not how it does it internally. Implementation-coupled tests break every time you refactor, even when behavior is unchanged — that is worse than useless.

✗ Tests Implementation

// Fragile: knows internal structure
expect(user._data.roles).toContain('admin');
expect(service.cache.size).toBe(3);
expect(spy.calls[2].args[0]).toBe('x');

✓ Tests Behavior

// Robust: verifies observable outcomes
expect(user.isAdmin()).toBe(true);
expect(service.getAll()).toHaveLength(3);
expect(result).toEqual(expected);

Test Boundaries

The most bugs live at the edges: empty inputs, maximum values, off-by-one errors, null, undefined, special characters. A single boundary test often catches more bugs than ten happy-path tests.

Don't Test the Framework

You do not need to test that React renders a <div>, that Express routes requests, or that Spring injects dependencies. The framework authors already tested that. Test your code that uses the framework.

Don't Test Trivial Code

Simple getters, setters, one-line delegations, and data classes do not need individual tests. If a function has no conditional logic and merely passes data through, a test adds cost without value.

Test the Things That Scare You

💨

Fear-Driven Testing

If you are afraid to change a piece of code, that is exactly the code that needs tests. Payment processing, authorization logic, data migrations, race conditions — test what keeps you up at night.

Risk-Based Testing

Prioritize tests by: (1) probability the code will break, (2) impact if it does break, and (3) how often the code changes. High-risk, high-change code gets heavy testing. Low-risk, stable code gets light testing.

Coverage as a Metric: Why 100% is a Trap

Code coverage tells you what code was executed during tests, not what was verified. You can have 100% coverage with zero assertions. Coverage is a useful indicator of untested areas, but a poor goal in itself.

🎯

Healthy Coverage Guidelines

Use coverage to find blind spots, not as a target. 80% coverage with meaningful assertions beats 100% coverage with superficial tests. If your team mandates a metric, 80-90% is pragmatic for most codebases.

4 Unit Testing Deep Dive

What Is a "Unit"?

The definition of a "unit" is one of testing's oldest debates. It depends on context:

PerspectiveUnit IsWhen This Makes Sense
Function-levelA single function or methodPure functions, utility code, algorithms
Class-levelA class with its internal methodsOOP-heavy code with encapsulated state
Module-levelA cohesive group of functions/classesWhen public API is the meaningful boundary
BehavioralA single behavior (may cross classes)When behavior spans collaborators naturally

The key property of a unit test is not the size of the unit, but that the test is fast, isolated, and deterministic. If it needs a database, it is not a unit test.

Arrange-Act-Assert Pattern (AAA)

Every unit test should have three clearly separated phases:

// ARRANGE: Set up the preconditions
const cart = new ShoppingCart();
cart.add(new Item('Widget', 25.00));
cart.add(new Item('Gadget', 15.00));

// ACT: Execute the behavior under test
const total = cart.calculateTotal({ discount: 0.1 });

// ASSERT: Verify the outcome
expect(total).toBe(36.00); // (25 + 15) * 0.9

One Assertion per Concept

This does not mean literally one expect() call per test — it means each test should verify one logical concept. Multiple assertions are fine if they all verify aspects of the same behavior.

// Good: multiple assertions verifying one concept (transfer outcome)
it('should transfer funds between accounts', () => {
    const result = transfer(sender, receiver, 100);

    expect(result.success).toBe(true);
    expect(sender.balance).toBe(400);   // was 500
    expect(receiver.balance).toBe(200); // was 100
});

Naming Conventions

ConventionExamplePopular In
should_X_when_Yshould_reject_when_balance_insufficientJavaScript, Ruby
givenX_whenY_thenZgivenLowBalance_whenTransfer_thenRejectJava, C#
Plain sentence'rejects transfer with insufficient funds'JavaScript (Jest, Vitest)
test_Xtest_rejects_insufficient_balancePython

The convention matters less than consistency. Pick one and enforce it across the team.

Test Isolation

Each test must be independent. Tests must not share mutable state, depend on execution order, or leave side effects for other tests. Use beforeEach/setUp to create fresh state, and tear down after.

Fast Feedback

Your unit tests should run in seconds, not minutes. If your unit test suite takes more than 30 seconds, something is wrong — probably I/O (database, filesystem, network) has leaked into your unit tests.

⏱

The 5-Second Rule

Developers should be able to run the unit tests for the module they are working on in under 5 seconds. Anything slower and they stop running tests during development.

5 Test Doubles

"Test double" is the umbrella term (from Gerard Meszaros) for any object that stands in for a real dependency in a test. There are five types, and confusing them leads to misuse.

DoubleWhat It DoesExample
Dummy Passed around but never used. Fills a parameter slot. processOrder(order, dummyLogger)
Stub Returns canned answers. Does not track calls. stubRepo.findById = () => testUser
Spy Records calls for later verification. May also do real work. expect(spy).toHaveBeenCalledWith(42)
Mock Pre-programmed with expectations. Fails if not used correctly. mock.expects('save').once().withArgs(data)
Fake Working implementation, but simplified (in-memory DB, etc.). class FakeUserRepo extends Map { ... }

When to Use Each

// STUB: control the input to the system under test
const priceService = { getPrice: () => 99.99 };
const total = calculateTotal(items, priceService);

// SPY: verify an output / side effect happened
const emailSpy = jest.fn();
resetPassword(user, emailSpy);
expect(emailSpy).toHaveBeenCalledWith(user.email, expect.any(String));

// FAKE: need realistic behavior without real infrastructure
const fakeDb = new InMemoryDatabase();
fakeDb.insert({ id: 1, name: 'Alice' });
const service = new UserService(fakeDb);
expect(service.findById(1).name).toBe('Alice');

Mockist vs Classicist (London vs Detroit School)

London School (Mockist)

Mock all collaborators. Test each unit in complete isolation. Verify interactions between objects.

Pro: Pinpoints failures precisely.
Con: Tests break on refactoring. Over-mocking hides integration bugs.

Detroit School (Classicist)

Only mock external boundaries (DB, network, clock). Let collaborating objects work together.

Pro: Tests survive refactoring. Catches integration issues.
Con: Failure messages can be less precise.

Over-Mocking: The Anti-Pattern

When you mock so much that the test is essentially verifying that your mocking framework works, something has gone wrong. Signs of over-mocking:

⚠

Mock at Boundaries, Not Everywhere

Mock the things you do not own: HTTP APIs, databases, file systems, clocks, random number generators. Let your own code collaborate naturally in tests.

6 Test-Driven Development (TDD)

Red-Green-Refactor

🔴 RED: Write a failing test
→
🟢 GREEN: Make it pass (minimal code)
→
🔵 REFACTOR: Clean up, keep green
→
Repeat

The Three Laws of TDD (Robert C. Martin)

  1. You may not write production code unless you first have a failing test that requires it.
  2. You may not write more of a test than is sufficient to fail (and not compiling counts as failing).
  3. You may not write more production code than is sufficient to pass the currently failing test.

When TDD Helps

✅ Complex Business Logic

Tax calculations, pricing rules, state machines. TDD forces you to think about edge cases before writing code.

✅ API Design

Writing the test first shows you what the API feels like from the consumer's perspective. If the test is hard to write, the API is hard to use.

✅ Bug Fixes

Write a test that reproduces the bug, verify it fails, then fix it. Guaranteed regression test.

When TDD Hurts

❌ Exploratory / Spike Code

When you do not know what the solution looks like yet, writing tests first slows discovery. Spike first, then add tests.

❌ UI Layout

CSS positioning, visual design, animation timing. These are best verified visually, not programmatically.

❌ Rapidly Changing Prototypes

If the feature spec changes daily, TDD creates tests that are immediately thrown away. Wait for stability.

TDD as a Design Tool

The most underappreciated aspect of TDD: it is not primarily a testing technique — it is a design technique. When code is hard to test, it is usually hard to use. TDD applies design pressure that leads to smaller functions, clearer interfaces, and explicit dependencies.

Practical TDD Walkthrough: FizzBuzz

// Step 1: RED — write the simplest failing test
test('returns "1" for input 1', () => {
    expect(fizzBuzz(1)).toBe('1');
});

// Step 2: GREEN — minimal implementation
function fizzBuzz(n) { return String(n); }

// Step 3: RED — next test
test('returns "Fizz" for multiples of 3', () => {
    expect(fizzBuzz(3)).toBe('Fizz');
});

// Step 4: GREEN
function fizzBuzz(n) {
    if (n % 3 === 0) return 'Fizz';
    return String(n);
}

// Step 5: RED — next test
test('returns "Buzz" for multiples of 5', () => {
    expect(fizzBuzz(5)).toBe('Buzz');
});

// Step 6: GREEN
function fizzBuzz(n) {
    if (n % 3 === 0) return 'Fizz';
    if (n % 5 === 0) return 'Buzz';
    return String(n);
}

// Step 7: RED — the combination case
test('returns "FizzBuzz" for multiples of both 3 and 5', () => {
    expect(fizzBuzz(15)).toBe('FizzBuzz');
});

// Step 8: GREEN + REFACTOR
function fizzBuzz(n) {
    let result = '';
    if (n % 3 === 0) result += 'Fizz';
    if (n % 5 === 0) result += 'Buzz';
    return result || String(n);
}

7 Property-Based Testing

What It Is

Instead of writing specific example inputs and expected outputs, you describe properties that must hold for all valid inputs. The framework generates hundreds or thousands of random inputs and checks each one.

Example-Based

test('sort works', () => {
  expect(sort([3,1,2])).toEqual([1,2,3]);
  expect(sort([5,5,5])).toEqual([5,5,5]);
  expect(sort([])).toEqual([]);
});

Tests 3 specific cases. Misses edge cases you did not think of.

Property-Based

test('sort properties', () => {
  fc.assert(fc.property(
    fc.array(fc.integer()),
    (arr) => {
      const sorted = sort(arr);
      // Property 1: same length
      expect(sorted.length).toBe(arr.length);
      // Property 2: ordered
      for (let i = 1; i < sorted.length; i++)
        expect(sorted[i] >= sorted[i-1])
          .toBe(true);
      // Property 3: same elements
      expect(sorted.sort()).toEqual(
        arr.sort());
    }
  ));
});

Tests thousands of random arrays. Finds edge cases automatically.

Shrinking

When a property-based test finds a failing input, the framework "shrinks" it — finding the smallest input that still triggers the failure. Instead of seeing [847, -293, 0, 44, ...] fail, you might see [0, -1] — much easier to debug.

When Property-Based Testing Shines

🔄 Parsers & Serializers

parse(serialize(x)) === x for all valid x. The roundtrip property catches countless edge cases.

🔢 Algorithms

Sort is idempotent, search always finds inserted items, encode/decode is lossless. Mathematical properties are perfect.

🛡 Invariants

Balance never goes negative, total equals sum of parts, output length within bounds. State invariants are natural properties.

Tools by Language

LanguageLibraryNotes
JavaScript/TSfast-checkMost popular, excellent shrinking, rich generators
PythonHypothesisGold standard. Database of examples, stateful testing
Java/KotlinjqwikJUnit 5 native integration
HaskellQuickCheckThe original. Inspired all others
RustproptestHypothesis-inspired, integrated with cargo test
.NETFsCheckF#/C# compatible, QuickCheck-inspired

8 Integration Testing

What to Integrate

Integration tests verify that your code works correctly with external systems. The key boundaries to test:

🗃 Database

Queries return correct results, transactions work, migrations apply cleanly, constraints enforce rules.

🌐 HTTP APIs

Requests are formed correctly, responses are parsed, error codes are handled, timeouts work.

📩 Message Queues

Messages are published and consumed, ordering is preserved (or handled), dead-letter queues trigger.

📂 File System

Files are written/read correctly, paths are valid, permissions are checked, temp files are cleaned up.

Testcontainers

Testcontainers spins up real Docker containers (PostgreSQL, Redis, Kafka, etc.) for each test run. You get a real database with real behavior — no fake quirks.

// Java + Testcontainers example
@Testcontainers
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("testdb");

    @Test
    void shouldFindUserByEmail() {
        var repo = new UserRepository(postgres.getJdbcUrl());
        repo.save(new User("alice@test.com", "Alice"));

        var found = repo.findByEmail("alice@test.com");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Alice");
    }
}

In-Memory Alternatives

Real SystemIn-Memory AlternativeFidelity
PostgreSQL / MySQLH2, SQLite (in-memory mode)Medium — SQL dialects differ
RedisEmbedded Redis, fake-redisMedium — missing some commands
S3MinIO, LocalStackHigh — API-compatible
KafkaEmbeddedKafkaHigh — real Kafka, embedded

Clean-Up Strategies

  1. Transaction rollback: Wrap each test in a transaction, roll back after. Fast, clean, but does not work for tests that need committed data (e.g., testing isolation levels).
  2. Truncate after each test: Delete all data from tables between tests. Works universally but slightly slower.
  3. Unique databases: Each test gets its own database/schema. Maximum isolation, highest overhead.
  4. Disposable containers: Start a fresh container per test class. Clean slate guaranteed. Best with Testcontainers.

9 Database Testing

Test Data Strategies

Fixtures

Pre-defined data loaded from files (JSON, SQL, YAML). Simple but rigid. Hard to maintain as schema evolves. Good for read-only reference data.

Factories

Programmatic builders that create valid entities with sensible defaults. Override only what matters for the test. The gold standard.

const user = UserFactory.build({
    role: 'admin',
    verified: true
});
// All other fields have defaults

Builders

Fluent API pattern for constructing complex test objects step by step. Common in Java/C#.

User user = aUser()
    .withName("Alice")
    .withRole(ADMIN)
    .withVerified(true)
    .build();

Transaction Rollback Pattern

// Each test runs in a transaction that is rolled back
beforeEach(async () => {
    await db.query('BEGIN');
});

afterEach(async () => {
    await db.query('ROLLBACK');
});

test('inserting a user', async () => {
    await db.query("INSERT INTO users (name) VALUES ('Alice')");
    const result = await db.query("SELECT * FROM users WHERE name = 'Alice'");
    expect(result.rows).toHaveLength(1);
    // Automatically rolled back — database is clean for next test
});

Migration Testing

Test that migrations apply cleanly by running them against an empty database in CI. Also test rollback (down) migrations. A broken migration in production is one of the most painful failures to recover from.

# In CI pipeline
db:create test_migration_db
db:migrate test_migration_db   # Apply all up migrations
db:rollback test_migration_db  # Apply all down migrations
db:migrate test_migration_db   # Re-apply — should succeed cleanly

Test Queries, Not Just the ORM

ORMs abstract SQL, but the generated SQL may not be what you expect. For complex queries (joins, aggregations, window functions), write integration tests that hit a real database and verify actual results.

Isolation Between Tests

Database tests must not leak state. If test A inserts a user and test B counts users, test B will get unexpected results. Use transaction rollback, truncation, or unique identifiers to prevent cross-test contamination.

10 API Testing

Testing REST Endpoints

// Node.js + SuperTest
describe('POST /api/users', () => {
    it('creates a user and returns 201', async () => {
        const res = await request(app)
            .post('/api/users')
            .send({ name: 'Alice', email: 'alice@test.com' })
            .expect(201);

        expect(res.body.id).toBeDefined();
        expect(res.body.name).toBe('Alice');
        expect(res.body.email).toBe('alice@test.com');
    });

    it('returns 400 for missing required fields', async () => {
        await request(app)
            .post('/api/users')
            .send({ name: 'Alice' })  // missing email
            .expect(400);
    });

    it('returns 409 for duplicate email', async () => {
        await request(app).post('/api/users')
            .send({ name: 'Alice', email: 'alice@test.com' });

        await request(app).post('/api/users')
            .send({ name: 'Bob', email: 'alice@test.com' })
            .expect(409);
    });
});

Authentication in Tests

// Helper to create an authenticated request
function authenticatedRequest(role = 'user') {
    const token = generateTestToken({ role, userId: 'test-user-1' });
    return {
        get: (url) => request(app).get(url)
            .set('Authorization', `Bearer ${token}`),
        post: (url) => request(app).post(url)
            .set('Authorization', `Bearer ${token}`),
    };
}

it('admin can delete users', async () => {
    const admin = authenticatedRequest('admin');
    await admin.delete('/api/users/123').expect(204);
});

it('regular user cannot delete users', async () => {
    const user = authenticatedRequest('user');
    await user.delete('/api/users/123').expect(403);
});

Testing Error Cases

Do not just test the happy path. Systematically test every error status code your API can return:

StatusTest Scenario
400Invalid body, missing fields, wrong types, constraint violations
401No token, expired token, malformed token
403Valid token but insufficient permissions
404Resource does not exist, wrong ID format
409Duplicate creation, version conflict
422Semantically invalid (e.g., end date before start date)
429Rate limit exceeded
500Unexpected server error (simulate with dependency failure)

Tools

ToolLanguageStrengths
SuperTestJavaScriptFluent API, integrates with Express/Koa directly
REST AssuredJavaBDD-style API, JSON path assertions, built for Java
httpx / pytestPythonasync support, pairs with FastAPI/Django test client
HurlCLIPlain text format, CI-friendly, chain requests

11 E2E Testing

What E2E Proves

End-to-end tests verify that the entire system works together, from the user interface through the API layer, through the database, and back. They are the ultimate confidence check — and the ultimate maintenance burden.

The Cost

🐢 Slow

A single E2E test often takes 10-60 seconds. A suite of 200 can take an hour. This kills developer feedback loops.

🌮 Flaky

E2E tests depend on browsers, networks, timing, and data. Any instability becomes test flakiness. Teams report 5-30% flake rates.

💰 Expensive to Maintain

When the UI changes, E2E tests break. When the data model changes, E2E tests break. Maintenance cost is 5-10x that of unit tests.

When to Use E2E

Reserve E2E tests for critical user journeys — the flows that, if broken, cause real business impact:

🎯

The 10-Test Rule

If you could only have 10 E2E tests, which flows would you choose? Start there. Most teams discover that 10-30 well-chosen E2E tests cover 80%+ of their critical paths.

12 UI Testing

Selenium vs Cypress vs Playwright

FeatureSeleniumCypressPlaywright
LanguageJava, Python, C#, JS, RubyJavaScript onlyJS, Python, Java, .NET
BrowsersAll majorChrome, Firefox, EdgeChromium, Firefox, WebKit
ArchitectureWebDriver protocol (out-of-process)Runs inside browserCDP/browser protocol
SpeedModerateFastFast
Auto-waitManualBuilt-inBuilt-in
ParallelVia Selenium GridExperimentalNative
Best forLegacy, multi-language teamsJS teams, component testsCross-browser, modern teams

Page Object Pattern

// Page object encapsulates selectors and actions
class LoginPage {
    constructor(page) { this.page = page; }

    async goto() {
        await this.page.goto('/login');
    }

    async login(email, password) {
        await this.page.fill('[data-testid="email"]', email);
        await this.page.fill('[data-testid="password"]', password);
        await this.page.click('[data-testid="login-btn"]');
    }

    async getError() {
        return this.page.textContent('[data-testid="error-msg"]');
    }
}

// Test reads like a user story
test('shows error for invalid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('wrong@test.com', 'bad-password');
    expect(await loginPage.getError()).toBe('Invalid credentials');
});

Selector Strategy

StrategyExampleVerdict
data-testid[data-testid="submit-btn"]Best Resilient to styling/structure changes
Role / accessible namegetByRole('button', {name: 'Submit'})Great Tests accessibility too
Text contentgetByText('Submit Order')OK Breaks on copy changes
CSS class.btn-primaryAvoid Breaks on style refactors
XPath//div[3]/span[1]/aWorst Fragile to any DOM change

Visual Regression Testing

Tools like Percy, Chromatic, and Playwright's screenshot comparison capture screenshots of your UI and compare them pixel-by-pixel against a baseline. This catches visual bugs (layout shifts, font changes, missing elements) that functional tests miss.

13 Flaky Tests

A flaky test is one that sometimes passes and sometimes fails with no code change. Flaky tests erode trust in the test suite faster than almost anything else. Once developers start ignoring test failures ("oh, that one is flaky"), the entire quality culture degrades.

Top 5 Causes and Fixes

#CauseSymptomFix
1 Timing / async race conditions Passes locally, fails in CI. Fails under load. Use proper await, auto-waiting selectors. Never use sleep().
2 Test order dependency Fails when run alone, passes in suite (or vice versa). Ensure each test sets up its own state. Run tests in random order to detect.
3 Shared mutable state Fails intermittently when run in parallel. Isolate state per test. No global variables, no shared database rows.
4 External dependency Fails when third-party API is slow or down. Mock external services. Use contract tests instead of live calls.
5 Time-sensitive logic Fails at midnight, month boundaries, DST changes. Inject a clock. Never use Date.now() / System.currentTimeMillis() directly.

Detection and Quarantine

  1. Detect: Run tests multiple times in CI (e.g., 5 retries). Track pass/fail rates per test. Any test below 100% pass rate is flaky.
  2. Quarantine: Move flaky tests to a separate suite that does not block the build. Tag them with @flaky or skip.
  3. Fix: Assign ownership. Treat flaky tests as bugs with the same priority as production bugs.
  4. Prevent: Add flaky-test detection to CI. Alert when a previously stable test becomes flaky.
🐛

A Flaky Test Is a Bug

Either the test is wrong (fix the test), the code has a race condition (fix the code), or the test environment is unstable (fix the infrastructure). Flaky tests are never acceptable as a permanent state.

14 Contract Testing

Consumer-Driven Contracts (Pact)

In a microservices world, Service A depends on Service B's API. A contract test captures what Service A expects from Service B (the "contract"), then verifies that Service B actually fulfills it — without deploying both services together.

Consumer (Service A) Provider (Service B) | | | 1. Writes contract: | | "I expect GET /users/1 | | to return {id:1, name:string}" | | | | 2. Publishes contract to Pact Broker | | -------- contract --------> | | | | 3. Provider verifies: | | "Can I fulfill this | | contract?" | | | | <------- result (pass/fail) --- |

When to Use Contract Testing

🔗

Microservices with Multiple Consumers

Contract testing shines when multiple services depend on the same provider API. It catches breaking changes before deployment, without the cost of running full E2E tests across all services.

API Evolution Safety

Contract tests enable safe API evolution. You can add new fields (backward-compatible) and the contracts keep working. If you remove or rename a field that any consumer uses, the provider verification fails before the change reaches production.

// Consumer test (Pact - JavaScript)
describe('User API contract', () => {
    it('returns user by ID', async () => {
        await provider.addInteraction({
            state: 'user 1 exists',
            uponReceiving: 'a request for user 1',
            withRequest: {
                method: 'GET',
                path: '/api/users/1'
            },
            willRespondWith: {
                status: 200,
                body: {
                    id: like(1),
                    name: like('Alice'),
                    email: like('alice@example.com')
                }
            }
        });

        const user = await userClient.getUser(1);
        expect(user.name).toBeDefined();
    });
});

15 Mutation Testing

What It Is

Mutation testing measures test quality by injecting small bugs ("mutants") into your source code and checking whether your tests catch them. If a test suite has 90% code coverage but only catches 60% of mutants, those tests are giving you false confidence.

Original Code: if (age >= 18) return "adult"; Mutant 1 (boundary): if (age > 18) return "adult"; // Changed >= to > Mutant 2 (negation): if (age < 18) return "adult"; // Changed >= to < Mutant 3 (return val): if (age >= 18) return "child"; // Changed return value Mutant 4 (removal): /* removed entire if block */ // Deleted statement Run tests against each mutant: - Mutant killed = tests caught the bug ✓ - Mutant survived = tests missed the bug ✗

Mutation Score vs Code Coverage

Code Coverage

"Was this line executed?"

Can be 100% with zero assertions. Measures breadth, not depth. Easy to game.

Mutation Score

"Would tests catch a bug here?"

Measures actual test effectiveness. Cannot be gamed. Much more honest signal of quality.

Tools

ToolLanguageNotes
StrykerJavaScript / TypeScriptWell-maintained, rich reporting, supports most test runners
PIT (Pitest)JavaFast (incremental), Maven/Gradle plugins, industry standard
mutmutPythonSimple CLI, integrates with pytest
cargo-mutantsRustAutomatic mutation of Rust code

Practical Example

// Your function
function calculateDiscount(total, membershipLevel) {
    if (membershipLevel === 'gold' && total > 100) {
        return total * 0.2;
    }
    if (membershipLevel === 'silver' && total > 50) {
        return total * 0.1;
    }
    return 0;
}

// A test with 100% coverage but low mutation score:
test('gold member gets discount', () => {
    expect(calculateDiscount(200, 'gold')).toBe(40);
});
test('no discount for basic', () => {
    expect(calculateDiscount(200, 'basic')).toBe(0);
});

// Missing: what about gold with total = 100? (boundary)
// Missing: silver path not tested at all!
// Mutation testing reveals these gaps. Coverage does not.

16 Snapshot Testing

What It Captures

Snapshot testing serializes the output of your code (rendered component, API response, config file) and saves it. On subsequent runs, the output is compared against the saved snapshot. Any change triggers a failure.

// Jest snapshot test
test('renders user card', () => {
    const tree = render(<UserCard name="Alice" role="admin" />);
    expect(tree).toMatchSnapshot();
});

// First run: creates __snapshots__/UserCard.test.js.snap
// Subsequent runs: compares output against saved snapshot
// If output changes: test fails, developer reviews the diff

When Useful

✅ Component Rendering

Catch unintended changes to React/Vue component output. "I changed the button text and suddenly the header changed too."

✅ API Responses

Ensure API response shapes do not change unexpectedly. Acts as a lightweight contract.

✅ Config Generation

Verify generated Terraform, YAML, or other configs match expected output exactly.

When Dangerous

⚠

The "Update All Snapshots" Trap

When developers blindly run jest --updateSnapshot without reviewing diffs, snapshots become rubber stamps that approve any change. They provide false confidence — worse than having no test at all.

Golden Master Testing

A variation of snapshot testing: capture the output of a legacy system as a "golden master," then verify that your rewrite produces identical output. Extremely useful for migrations and rewrites where behavior preservation is the goal.

17 Chaos Engineering

Testing in Production

Chaos engineering is the discipline of experimenting on a system in order to build confidence in the system's capability to withstand turbulent conditions in production. It goes beyond traditional testing by verifying resilience under real-world failure modes.

The Steady State Hypothesis

  1. Define steady state: What does "working" look like? (e.g., p99 latency < 200ms, error rate < 0.1%, all health checks green)
  2. Hypothesize: "If database replica fails, the system will failover and maintain steady state."
  3. Inject failure: Kill the database replica.
  4. Observe: Did steady state hold? How long was the disruption?
  5. Learn and improve: Fix whatever broke. Add monitoring. Run the experiment again.

Fault Injection Types

🌐 Network

Latency injection, packet loss, DNS failures, partition between services. The most common and most revealing fault type.

💻 Compute

Kill processes, exhaust CPU, fill memory, crash a node. Tests auto-scaling, restarts, and health checks.

💾 Storage

Fill disk, corrupt files, slow I/O, kill database connections. Tests data resilience and graceful degradation.

🔌 Dependencies

Make a downstream service return 500s, timeout, or return garbage. Tests circuit breakers, fallbacks, and retry logic.

Tools

ToolScopeNotes
Chaos MonkeyVM/container terminationNetflix original. Randomly kills instances. The classic.
GremlinFull platformCommercial. Network, CPU, disk, process-level attacks. SaaS.
LitmusChaosKubernetesCNCF project. Cloud-native, open source, experiment CRDs.
ToxiproxyNetworkShopify. TCP proxy that simulates network conditions.

Game Days

A game day is a scheduled, team-wide chaos experiment. The team gathers, announces the experiment, injects a failure, and observes how the system (and the team) responds. Game days build both system resilience and team readiness.

18 Performance Testing

Types of Performance Tests

TypeGoalPatternDuration
Load TestVerify expected trafficRamp to expected peak, hold30-60 min
Stress TestFind breaking pointRamp beyond capacity until failureUntil break
Soak TestFind memory leaks, degradationModerate load, extended time4-24 hours
Spike TestTest sudden burst handlingInstant jump to high load10-30 min

Tools

k6

Write tests in JavaScript. Modern, developer-friendly, great CLI output. Excellent for CI integration. Open source (Grafana Labs).

import http from 'k6/http';
import { check } from 'k6';

export const options = {
    stages: [
        { duration: '30s', target: 50 },
        { duration: '1m',  target: 50 },
        { duration: '10s', target: 0 },
    ],
    thresholds: {
        http_req_duration: ['p(95)<200'],
    },
};

export default function () {
    const res = http.get('https://api.example.com/users');
    check(res, {
        'status 200': (r) => r.status === 200,
        'latency < 200ms': (r) => r.timings.duration < 200,
    });
}

Gatling

Scala/Java-based. Rich reporting. Simulates complex user journeys. Popular in enterprise Java shops.

JMeter

Apache project. GUI-based test builder. Huge protocol support. Heavier, but battle-tested over 20+ years.

Setting Baselines and Detecting Regressions

Record performance metrics (p50, p95, p99 latency; throughput; error rate) for each release. Store them. Compare each new release against the baseline. Alert if any metric degrades beyond a threshold (e.g., p95 latency increases by >10%).

Release p50 (ms) p95 (ms) p99 (ms) Throughput Errors v2.3.0 12 45 120 1,200 rps 0.01% v2.3.1 12 44 118 1,210 rps 0.01% ✓ Stable v2.4.0 15 89 340 1,100 rps 0.05% ⚠ REGRESSION v2.4.1 13 48 125 1,190 rps 0.01% ✓ Fixed

19 Test Organization

Folder Structure

By Feature (Co-located)

src/
  users/
    UserService.ts
    UserService.test.ts
    UserRepository.ts
    UserRepository.test.ts
  orders/
    OrderService.ts
    OrderService.test.ts

Best for: Feature teams, microservices, modular monoliths.

By Type (Separated)

src/
  users/
    UserService.ts
    UserRepository.ts
test/
  unit/
    UserService.test.ts
  integration/
    UserRepository.test.ts
  e2e/
    user-flow.test.ts

Best for: Projects with distinct test stages and CI pipelines per type.

Naming Files

Consistent naming conventions make it easy to find tests and to configure test runners:

ConventionExampleFramework
*.test.tsUserService.test.tsJest, Vitest
*.spec.tsUserService.spec.tsJasmine, Angular
test_*.pytest_user_service.pypytest
*Test.javaUserServiceTest.javaJUnit
*_test.gouser_service_test.goGo testing

Shared Fixtures and Helpers

Extract common setup into shared helpers: factories, builders, custom matchers, test data generators. Place them in a test/support or test/helpers directory. Treat them with the same quality standards as production code.

Parallel Execution

Run tests in parallel to reduce wall-clock time. Requirements: tests must be independent (no shared mutable state), test data must be isolated (unique IDs or separate databases), and the test runner must support it (Jest workers, pytest-xdist, JUnit parallel execution).

Test Suites and Tags

// Tag tests for selective execution
describe('UserService', () => {
    it.each(['@unit', '@fast'])('calculates age from birthdate', ...);
    it.each(['@integration', '@slow'])('persists user to database', ...);
});

// Run only fast tests during development:
// jest --testPathPattern=unit
// pytest -m "not slow"
// go test -run TestUnit ./...

20 Testing in CI/CD

Test Stages

Lint + Type Check
→
Unit Tests
→
Integration Tests
→
E2E Tests
→
Performance Tests
→
Deploy

Each stage acts as a gate. If unit tests fail, do not waste time running integration tests. Fail fast, give feedback early.

Parallelization and Test Splitting

# GitHub Actions: parallel test jobs
jobs:
  unit-tests:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npx jest --shard=${{ matrix.shard }}/4

  integration-tests:
    needs: unit-tests
    services:
      postgres:
        image: postgres:16
    steps:
      - run: npm run test:integration

Test Reporting

Use standard reporting formats so CI systems can parse and display results:

FormatSupported ByNotes
JUnit XMLJenkins, GitHub Actions, GitLab, CircleCIUniversal standard. Every test framework can export it.
AllureAllure Server, Jenkins pluginRich HTML reports with history, categories, flaky detection.
TAPNode.js ecosystemSimple text format. Easy to parse, human-readable.

Required Checks and Flaky Test Handling

Mark critical test suites as required checks that block merging. For flaky tests, use a quarantine strategy: move them to a non-blocking suite, fix them within a sprint, and promote them back.

🚨

Never Auto-Retry to Hide Flakiness

Some teams add automatic retries to CI. This masks flakiness instead of fixing it. If you must retry, log every retry and track flake rates. Fix the root cause within a sprint.

21 Mocking External Services

WireMock

A programmable HTTP mock server. Define stubs declaratively or programmatically. Runs standalone or embedded in JVM tests.

// WireMock stub
stubFor(get(urlEqualTo("/api/products/42"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"id\": 42, \"name\": \"Widget\", \"price\": 29.99}")
    )
);

// Your code calls http://localhost:8080/api/products/42
// and gets the stubbed response

Record and Replay (VCR Pattern)

Record real HTTP interactions once, save them as "cassettes" (files), and replay them in subsequent test runs. No live network calls after initial recording.

ToolLanguageFormat
VCR.pyPythonYAML cassettes
Polly.JSJavaScriptHAR or JSON
VCR (Ruby)RubyYAML cassettes
BetamaxJavaJSON tapes
# Python + VCR.py
@vcr.use_cassette('cassettes/product_api.yaml')
def test_get_product():
    product = client.get_product(42)
    assert product['name'] == 'Widget'
    assert product['price'] == 29.99
    # First run: makes real HTTP call, records response
    # Subsequent runs: replays from cassette file

Service Virtualization

For complex external services with stateful interactions (e.g., a payment gateway with auth, charge, refund flow), service virtualization tools like Hoverfly or Mountebank can simulate entire stateful services.

Contract Testing as Alternative

Instead of mocking what you think the external service returns, use contract testing to verify what it actually returns. Combine: mock for speed in local tests, verify contracts in CI to catch drift.

22 Testing Legacy Code

The Legacy Code Dilemma

+--------------------------------------------------+ | | | "I can't refactor without tests, | | but I can't add tests without refactoring." | | | | -- Every developer inheriting legacy code | | | +--------------------------------------------------+
"Legacy code is code without tests." — Michael Feathers, Working Effectively with Legacy Code

Characterization Tests

A characterization test documents the current behavior of code, whether correct or not. It is a safety net for refactoring: if the test passes after your change, you preserved the existing behavior.

// Characterization test: capture what the code currently does
test('calculateShipping returns 5.99 for orders under 50', () => {
    // We don't know WHY 5.99, but that's what it currently returns
    const result = calculateShipping({ total: 49.99, country: 'US' });
    expect(result).toBe(5.99);
});

test('calculateShipping returns 0 for orders 50 and above', () => {
    const result = calculateShipping({ total: 50.00, country: 'US' });
    expect(result).toBe(0);
    // Note: is this correct? It's what the code does NOW.
    // Document as a characterization test, not a specification test.
});

Finding Seams

A "seam" (Michael Feathers' term) is a place where you can change behavior without editing the code. Types of seams:

Object Seam

Override a method or inject a dependency. The most common and most useful seam in OOP code.

Preprocessing Seam

Use compiler directives or environment variables to swap implementations (e.g., #ifdef TEST).

Link Seam

Swap a library or module at link/import time. Common in C/C++ and JavaScript (module mocking).

The Strangler Fig Pattern for Tests

Do not try to test the entire legacy codebase at once. Instead, wrap new tests around the code you are about to change. Over time, test coverage grows organically around the areas that change most often — which are exactly the areas that need tests most.

Approval Testing

Capture the complete output of a function or system (HTML, JSON, text, screenshot) and approve it as the baseline. Any change to the output requires explicit approval. Tools: ApprovalTests (Java, C#, Python), Jest snapshots, verify (Rust).

23 Test Maintenance

When Tests Slow You Down

If adding a feature requires changing 50 tests that all verify internal implementation details, your test suite is a liability, not an asset. This is the most common reason teams lose faith in testing.

Test Rot

Signs that your test suite is rotting:

The Maintenance Cost Formula

Test Value = (Bugs Caught x Bug Cost) - Maintenance Cost Where: Bugs Caught = likelihood of catching a real regression Bug Cost = cost of the bug reaching production Maintenance Cost = time spent updating, debugging, and fixing the test If Maintenance Cost > Bugs Caught x Bug Cost: DELETE THE TEST. It is not earning its keep.

Refactoring Tests

Apply the same quality standards to test code as production code:

Extract Helpers

Repeated setup logic becomes a factory or builder. createUser({admin: true}) beats 15 lines of setup in every test.

Delete Duplicates

If three tests verify the same behavior with slightly different inputs, consolidate into a parameterized test.

Improve Naming

A test named test1 or testCalculate tells you nothing. Rename to describe behavior: rejects_negative_quantities.

📖

Test Code Is Production Code

Review test code in PRs. Refactor test code. Apply DRY (but not excessively). Name things well. If your tests are unreadable, they are unmaintainable.

24 Testing Culture

Who Writes Tests?

Developers. Not "the QA team." Not "the test engineers." The person who writes the code writes the tests. QA engineers bring testing expertise, exploratory testing skills, and a different perspective — but every developer should be competent at writing automated tests.

Testing as a First-Class Activity

In Estimation

If a feature takes 3 days to build, that includes tests. Testing is not a separate phase that happens "after."

In Code Review

Review tests with the same rigor as production code. Ask: "What is this test verifying? Is the assertion meaningful? Are edge cases covered?"

In Definition of Done

"Done" means tested. A feature without tests is not done, period. Build this into your team's process explicitly.

Making Testing Easy

  1. Good tooling: Fast test runners, watch mode, clear error messages. If running tests is painful, developers will not do it.
  2. Fast feedback: Unit tests in < 5 seconds. Full suite in < 10 minutes. If the suite is slow, developers stop running it.
  3. Good examples: Maintain a set of well-written reference tests in the codebase. New developers learn by reading examples.
  4. Low ceremony: Creating a new test should require zero boilerplate. One file, one import, start writing.
  5. Helpful CI: When tests fail in CI, the failure message should tell the developer exactly what went wrong and where.

Test Quality Metrics

MetricWhat It MeasuresTarget
Test suite durationFeedback loop speed< 10 min for full suite
Flaky test rateTest reliability< 0.5% of all tests
Code coverage (as a trend)Testing breadthTrending up, not a target number
Mutation scoreTest effectiveness> 70% for critical modules
Test-to-code ratioTesting effort1:1 to 2:1 (lines of test vs production)

25 Testing Strategy by Project Type

Startup / MVP

🚀 Move Fast, Test Smart

You cannot afford a full test suite for code that might be thrown away next week. Focus on:

  • Contract tests for API boundaries (so frontend and backend can develop independently)
  • Smoke tests for critical paths (registration, core feature, payment)
  • Unit tests only for genuinely complex logic

Goal: Ship fast without breaking the critical things.

Growing Product

📈 Build the Pyramid

As the codebase grows, invest in a proper test pyramid. You now have users relying on existing features — regressions become expensive.

  • Comprehensive unit tests for business logic
  • Integration tests for all database and API boundaries
  • E2E tests for top 10 user journeys
  • CI/CD with test gates

Goal: Confidence to refactor and deploy frequently.

Enterprise

🏢 Compliance and Thoroughness

Regulated industries (finance, healthcare) require audit trails, compliance evidence, and extensive test coverage.

  • All pyramid layers, heavily documented
  • Compliance-specific tests (HIPAA, PCI-DSS, SOX checks)
  • Extensive E2E for audit trails and access controls
  • Performance and security testing (OWASP, penetration)

Goal: Demonstrate compliance. Prevent liability.

Library / Framework

📚 Exhaustive Verification

Libraries are used by thousands of projects. A bug in your library is a bug in every project that depends on it.

  • Exhaustive unit tests for every public API surface
  • Property-based testing for parsers, serializers, algorithms
  • Backward compatibility tests (previous version's tests still pass)
  • Mutation testing to verify test quality

Goal: Absolute reliability. Users depend on your correctness.

Microservices

🔗 Contracts Are King

In a distributed system, the boundaries between services are the biggest risk. An internal change in Service B can break Service A with no warning.

  • Contract testing (Pact) for every service-to-service boundary
  • Unit tests within each service (standard pyramid per service)
  • Integration tests with Testcontainers for each service's dependencies
  • Minimal cross-service E2E tests (only critical business flows)
  • Chaos engineering to test resilience

Goal: Prevent integration breakage. Deploy independently with confidence.

Summary Matrix

Project TypeUnitIntegrationE2EContractPropertyMutationChaos
Startup/MVPLowLowSmokeMedium---
Growing ProductHighMediumMediumMediumLowLow-
EnterpriseHighHighHighHighMediumMediumMedium
LibraryExhaustiveLow--HighHigh-
MicroservicesMediumHighLowCriticalLowLowHigh