The Testing Pyramid — What You Might Get Wrong About Testing

When you are building a larger piece of software, writing good tests isn't optional — it's the only way to keep shipping without fear. As complexity grows, so does the chance of nasty bugs that are expensive to find and fix. If you introduce a defect, your test suite should catch it immediately, while the code is still fresh in your head.

Most teams think about tests in three layers:

  • Unit tests verify a small piece of behavior in isolation — e.g. email validation.
  • Integration tests verify how components work together across boundaries like databases or HTTP — e.g. a registration flow persisting to the DB and emitting a domain event.
  • End-to-end (E2E) tests verify a whole user journey in a production-like environment — e.g. a user can create an account and receive a confirmation email.

Unit tests

A unit test verifies one small, well-defined behavior in isolation. The unit can be a pure function, a method, or a small domain service — the key is that the behavior is narrow, has clear inputs and outputs, and doesn't rely on slow or unpredictable external systems. Isolation means no real I/O: no database, no network, no file system, no system clock you don't control. Where the code would reach across a boundary, you provide a test double.

Pick the unit to align with a single business rule or decision: "an email is valid if it matches this pattern," "a password reset token expires after 15 minutes." Each test reads like a tiny spec and focuses on observable outcomes — return values, state changes, events — not private implementation details.

Why it matters: millisecond feedback keeps the TDD loop fast; writing units first nudges you toward pure functions and clear boundaries; logic defects surface before any framework is involved; and each test encodes a business rule as living documentation.

Best practices:

  • Test behavior, not implementation — assert outputs, state, and events, not call order.
  • One reason to fail — clear Arrange–Act–Assert with a single behavioral theme.
  • Control nondeterminism — inject clocks and ID generators; avoid I/O, randomness, and sleeps.
  • Prefer simple in-memory fakes over heavy mock trees.
  • Name tests like specs and hit boundary and invalid cases.

Anti-patterns: asserting private methods or call order; over-mocking; touching DB, network, or files; huge noisy fixtures; many behaviors per test; conditionals and loops inside assertions.

class EmailValidator {
    private val pattern = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")
    fun isValid(email: String) = pattern.matches(email)
}

class EmailValidatorTest {
    private val validator = EmailValidator()

    @Test
    fun `valid emails pass`() {
        listOf("ana@example.com", "john.doe+work@sub.domain.org").forEach {
            assertTrue(validator.isValid(it))
        }
    }

    @Test
    fun `invalid emails fail`() {
        listOf("no-at-symbol", "a@b", "@missinglocal.com").forEach {
            assertFalse(validator.isValid(it))
        }
    }
}

Cover: core business rules, boundary conditions, error paths, state transitions and invariants, pure transformations. Keep out: real I/O, framework wiring, cross-component behavior, multi-step workflows, concurrency and retries, HTTP routing.

Integration tests

An integration test verifies that multiple parts of your system work correctly together, especially across real boundaries where bugs love to hide: the database, HTTP, message queues, the file system, caches, and framework wiring. Instead of isolating a single class, you exercise a slice of the application — a service method plus its repository, or an HTTP controller plus JSON serialization and persistence — using real implementations at the boundary.

The goal is to validate the contract between components: schemas, mappings, transactions, configuration, and error handling. If your service should save a user, commit a transaction, emit an event, and return a 201 with a JSON body, an integration test proves those pieces line up when the code runs against something real.

Why it matters: it catches the plumbing bugs — misconfigurations, ORM mapping issues, migrations, serialization, transactions. It prevents "works on my machine," protects contracts between services and clients, and makes version bumps less scary.

Best practices:

  • Hit a real boundary in a disposable environment (Testcontainers, Docker Compose).
  • Hermetic and repeatable — each test creates and cleans its own data, runs in any order.
  • Prod-like wiring — apply migrations; verify ORM mappings, transactions, and config as in prod.
  • Control third parties with stubs, sandboxes, or consumer–provider contract tests.
  • Assert at the seam — rows, constraints, HTTP status and body, published messages; poll instead of sleeping.

Anti-patterns: mocking the boundary (that's a unit test in disguise); non-hermetic shared state; calling live third parties; skipping migrations; fixed sleeps; giant scenarios that duplicate logic checks.

@Testcontainers
@SpringBootTest
class RegistrationFlowIT {

    companion object {
        @Container
        val postgres = PostgreSQLContainer("postgres:16-alpine")
            .withDatabaseName("app")
    }

    @Autowired lateinit var registrationService: RegistrationService
    @Autowired lateinit var userRepository: UserRepository

    @Test
    fun `registers user, persists, and emits domain event`() {
        val outbox = InMemoryOutbox()
        registrationService.register("ana@example.com", "Ana", outbox)

        assertThat(userRepository.findByEmail("ana@example.com")).isPresent
        assertThat(outbox.events).anyMatch { it.type == "UserRegistered" }
    }
}

Cover: the persistence layer, transactions and consistency, the HTTP edge on both server and client, messaging and outbox processing, security integration. Keep out: pure domain logic, full user journeys, live third parties, visual rendering, performance and load.

End-to-end tests

An E2E test verifies a real user journey through your system, exercising the same boundaries and protocols a user or client would: browser or public API, network, auth, backend services, database, background workers. It proves the whole path behaves correctly in a production-like environment. The goal is not to test every edge case but to validate the golden paths that make or lose money — signup, checkout, password reset, critical workflows.

Why it matters: it guards revenue paths before and after deploys, verifies the app the way real users experience it, checks that services and infrastructure cooperate, and provides a simple high-signal release gate.

Best practices:

  • Protect critical journeys only — keep the suite small.
  • Run against an ephemeral, prod-like stack; sandbox emails and payments.
  • Assert on what users see; query by roles, labels, or data-testid.
  • Wait for conditions, never fixed sleeps.
  • Own your test data — unique identifiers, seeded via public APIs, parallel-friendly.
  • Capture screenshots and logs in CI; quarantine and fix flaky tests fast.

Anti-patterns: testing everything end-to-end; brittle CSS or xPath selectors; sleep-driven stability; shared or dirty data; asserting internals; real credentials or live payment providers.

import { test, expect } from '@playwright/test'

test('user can sign up', async ({ page }) => {
  await page.goto(process.env.APP_URL)
  await page.getByRole('link', { name: 'Sign up' }).click()

  const email = `ana+${Date.now()}@example.com`
  await page.getByLabel('Email').fill(email)
  await page.getByLabel('Password').fill('StrongP@ssw0rd!')
  await page.getByRole('button', { name: 'Create account' }).click()

  await expect(page.getByText('Check your email')).toBeVisible()
})

How the layers fit together

Think of coverage and cost:

  • Unit tests: ~70–85% of your suite. Fast, cheap to write and maintain.
  • Integration tests: ~15–25%. Slower, but high leverage at boundaries.
  • E2E tests: ~3–10%. Slowest and most brittle — reserve for revenue-critical paths.

In CI: every commit runs all unit tests plus a targeted subset of integration tests; merges to main run the full integration suite plus a small critical E2E pack; nightly runs the full E2E suite against a production-like environment.

If you remember only three things: drive new behavior with small focused unit tests, prove your boundaries with lean hermetic integration tests, and keep a tiny reliable E2E pack for your critical flows.


Originally published on Medium.