Skip to content
← Back to Codex
QA8 min read

QA Fundamentals: Testing Types and When to Use Them

Every testing conversation eventually lands on the same question: "what should we be testing, and how?" The answer depends on what you're building, but the framework for thinking about it is universal. Here's how I break down testing types and decide where to invest.

The Testing Pyramid

The pyramid is simple: lots of unit tests at the base, fewer integration tests in the middle, and a thin layer of E2E tests at the top. It's been around for over a decade, and it's still the most useful mental model for test strategy.

The reasoning is straightforward:

The mistake I see most often is an inverted pyramid: teams with 500 Selenium tests and 20 unit tests. Every test run takes an hour, half the failures are flaky, and nobody trusts the suite. Start from the bottom.

Unit Tests

Unit tests verify that individual functions produce correct output for given input. They should be:

function calculateDiscount(price: number, tier: 'gold' | 'silver' | 'none'): number {
  if (tier === 'gold') return price * 0.8;
  if (tier === 'silver') return price * 0.9;
  return price;
}

test('gold tier gets 20% discount', () => {
  expect(calculateDiscount(100, 'gold')).toBe(80);
});

test('no tier pays full price', () => {
  expect(calculateDiscount(100, 'none')).toBe(100);
});

Where unit tests shine: pure business logic, data transformations, validation rules, utility functions. Where they don't: anything that depends on how components interact.

Integration Tests

Integration tests verify that modules work together correctly. The boundary you're testing is the seam between your code and something external — a database, an API, a message queue.

test('creating a user persists to database', async () => {
  const user = await userService.create({
    email: 'test@example.com',
    name: 'Test User',
  });

  const found = await db.users.findByEmail('test@example.com');
  expect(found).not.toBeNull();
  expect(found.name).toBe('Test User');
});

Key difference from unit tests: integration tests use real dependencies. The database is a real database (often a test container). The API call hits a real endpoint (or a controlled test server). This makes them slower but catches bugs that mocks hide — like a SQL query that works in SQLite but fails in PostgreSQL.

End-to-End Tests

E2E tests simulate real user behavior in a real browser. They're the closest thing to "does this actually work for a user?"

test('user can complete checkout', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add to Cart' }).first().click();
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByRole('button', { name: 'Checkout' }).click();

  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

E2E tests are expensive. Each one takes seconds to minutes, requires browser infrastructure, and breaks when the UI changes. Use them sparingly:

Exploratory Testing

Exploratory testing is the one type that can't be automated, and it's the one most teams underinvest in. It's a QA engineer using the software with intent — not following a script, but actively trying to break it.

Effective exploratory testing is structured, not random:

Exploratory testing catches the bugs that automated tests miss: confusing UX flows, edge cases nobody thought to script, performance issues that only appear with realistic usage patterns. Every team needs someone doing this regularly.

Choosing the Right Mix

There's no universal ratio. The right mix depends on your application:

Data-heavy backends (APIs, ETL pipelines): Heavy on unit and integration tests. Minimal E2E — maybe just contract tests against the API surface.

User-facing web apps: Balanced pyramid. Strong unit tests for business logic, integration tests for API calls, and targeted E2E tests for critical flows.

Mobile apps: Similar to web, but E2E tests are more expensive (device farms, slow emulators). Invest more in integration tests and use E2E only for the most critical paths.

Legacy systems: Start with E2E tests to create a safety net, then add unit tests as you refactor. You can't unit-test code that wasn't designed for it without changing it first.

What I've Learned

The teams I've worked with that ship fastest with the fewest regressions share one trait: they test at the right level. They don't write E2E tests for logic that a unit test can cover. They don't mock their database when the integration is what needs testing. And they invest in exploratory testing because they know automated tests only find the bugs you anticipated.

The pyramid isn't a rule — it's a heuristic. But it's a good one.