A Practical Testing Strategy: Unit, Integration and End-to-End Tests
How to build a testing strategy that catches bugs without slowing you down — the test pyramid vs trophy, what to test at each level, mocking, flaky tests, coverage and CI.
Every team agrees testing matters. Far fewer agree on what to test, at which level, and how much is enough. The result is often one of two extremes: almost no tests, or thousands of brittle tests that slow every change and break on every refactor.
A good testing strategy maximizes confidence per minute of effort, both writing tests and running them. Here's how to build one.
Key takeaways
- Test behavior, not implementation. Tests that break when you refactor without changing behavior are liabilities.
- Use the right level: unit for logic, integration for components working together, end-to-end for critical user journeys.
- Integration tests often give the best confidence per test for web applications.
- Keep E2E tests few and focused on the flows that make money or would be catastrophic to break.
- Flaky tests are bugs; fix or delete them quickly.
The test levels
Unit tests
Test a small unit, a function or class, in isolation.
- Fast (milliseconds), precise failure messages.
- Ideal for pure business logic: pricing rules, validation, parsers, state machines.
- Weak at catching problems in how pieces connect.
tsdescribe("calculateDiscount", () => {
it("applies 10% for orders over $100", () => {
expect(calculateDiscount({ subtotal: 150, tier: "standard" })).toBe(15);
});
it("never discounts below zero total", () => {
expect(calculateDiscount({ subtotal: 5, tier: "vip", coupon: 20 })).toBe(5);
});
});
Integration tests
Test several components together with real collaborators where practical: your API handler plus database, or a service plus its repository.
- Catch wiring bugs: SQL errors, serialization issues, auth middleware, migrations.
- Slower than unit tests, but still fast if you use a real test database in a container and run tests in parallel.
tsit("POST /orders creates an order and returns 201", async () => {
const user = await seedUser();
const res = await request(app)
.post("/v1/orders")
.set("Authorization", tokenFor(user))
.send({ items: [{ sku: "SKU-1", quantity: 2 }] });
expect(res.status).toBe(201);
const saved = await db.orders.findById(res.body.id);
expect(saved?.customerId).toBe(user.customerId);
});
End-to-end (E2E) tests
Drive the real application like a user does, through a browser for web apps.
- Highest realism: catch problems no other test sees.
- Slowest, most expensive to maintain, and most prone to flakiness.
- Reserve them for critical journeys: sign-up, login, checkout, core workflows.
tstest("customer can complete checkout", async ({ page }) => {
await page.goto("/products/sku-1");
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "Checkout" }).click();
await page.getByLabel("Card number").fill("4242 4242 4242 4242");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
});
Pyramid, trophy or something else?
The classic test pyramid says: many unit tests, fewer integration tests, very few E2E tests. The testing trophy (popular for frontend and web apps) emphasizes integration tests as the biggest layer, since they best reflect how software is used.
Both agree on the important parts: lots of fast tests, few slow ones, and each test at the lowest level that gives real confidence. Adjust the mix to your system:
| System type | Emphasis |
|---|---|
| Library / algorithmic code | Mostly unit tests |
| Typical web API | Integration tests with a real DB + unit tests for complex logic |
| Frontend app | Component/integration tests + a handful of E2E journeys |
| Data pipeline | Data quality checks + transformation unit tests (data quality guide) |
| LLM features | Eval suites (LLM evaluation guide) |
What to test (and what not to)
Test
- Business rules and edge cases (boundaries, empty, null, very large).
- Anything that has broken before: every bug fix gets a regression test.
- Authorization: user A can't access user B's data.
- Critical integrations, via contract tests or a sandbox.
- Error paths: timeouts, invalid input, downstream failures.
Don't bother testing
- Framework or library internals.
- Trivial getters, setters and pass-through code.
- Implementation details like private methods, or exact call counts on internal helpers.
- Things the type system already guarantees (TypeScript best practices).
Mocking: use sparingly and at the edges
Mocks are useful for things you don't control: third-party APIs, email providers, payment gateways, time and randomness. Over-mocking internal collaborators creates tests that pass while the real system is broken, and that break whenever you refactor.
Guidelines:
- Mock at architectural boundaries (ports and adapters), not between internal classes. See clean architecture.
- Prefer fakes (in-memory implementations) over mocks with many expectations.
- Use a real database in integration tests; ephemeral containers make this practical.
- For third-party APIs, add a small number of contract tests against the provider's sandbox.
Flaky tests
A flaky test passes and fails without code changes. It erodes trust until everyone ignores red builds. Common causes and fixes:
| Cause | Fix |
|---|---|
| Timing / arbitrary sleeps | Wait for explicit conditions, not fixed delays |
| Shared state between tests | Isolate data; reset DB per test or use unique IDs |
| Test order dependence | Run tests in random order to expose it |
| Real network calls | Stub external services |
| Time and timezone dependence | Inject a clock; pin the timezone in CI |
| Animations in E2E | Disable animations in the test environment |
Policy: quarantine a flaky test immediately (with a ticket and owner), then fix or delete it within days.
Coverage: a signal, not a goal
Coverage tells you what code wasn't executed by tests. It doesn't tell you whether the executed code was checked meaningfully. Use it to find untested critical paths, not as a target to game. Many teams do well with high coverage on core domain logic and less on glue code.
Tests in CI
- Run unit and integration tests on every push; keep them under ~10 minutes with parallelization.
- Run E2E tests on pull requests for critical flows, and a broader suite before release.
- Block merges on failures.
- Track test duration and flakiness over time.
See the CI/CD pipeline guide for pipeline design.
A starting strategy for a typical web product
- Unit tests for all non-trivial business logic.
- Integration tests for every API endpoint: happy path, validation error and authorization failure.
- Five to fifteen E2E tests covering the critical journeys.
- A regression test with every bug fix.
- All of it in CI, blocking merges.
FAQ
How many tests are enough?
Enough that you can deploy confidently without manual testing of core flows. If you're afraid to refactor or deploy on a Friday, you need more (or better) tests in the risky areas.
Should I write tests first (TDD)?
TDD works very well for well-understood logic and bug fixes. For exploratory UI or prototype work, many teams write tests after the design settles. The outcome, meaningful tests for important behavior, matters more than the order.
Are E2E tests worth the maintenance cost?
For a small number of critical journeys, yes. They catch integration problems nothing else does. Problems start when teams try to test everything through the UI; keep the suite small and stable.
How do I test code that calls an LLM?
Unit-test the deterministic parts (prompt building, parsing, validation) and mock the model in regular tests. Evaluate model behavior separately with eval datasets and scoring, since outputs are non-deterministic.