DEV Community

Cover image for The Three Types of Testing Every Developer Should Know
Synfinity Dynamics Pvt Ltd
Synfinity Dynamics Pvt Ltd

Posted on

The Three Types of Testing Every Developer Should Know

Ask ten developers what "testing" means and you'll get ten different answers. That's because testing isn't one thing it's a layered strategy. Most modern testing philosophy boils down to three core types: unit testing, integration testing, and end-to-end (E2E) testing. Understanding what each one does and doesn't do is the difference between a test suite that actually catches bugs and one that just slows down your CI pipeline.

Early in my career I made the classic mistake: I wrote hundreds of unit tests, hit 90%+ coverage, felt great about it and then shipped a broken signup flow because the frontend was sending a field name the backend didn't expect. Every unit test passed. The app was still broken. That gap is exactly what integration and E2E testing exist to close.

In this article we'll go through what each type of testing actually does, what tools people commonly use for each, the mistakes teams make with each layer, and then walk through one real feature user signup tested all three ways so you can see how they fit together in practice.

Let's break them down.

1. Unit Testing

Unit tests check the smallest testable pieces of your code a single function, method, or class in complete isolation.

What it looks like:

function add(a, b) {
  return a + b;
}

test('adds two numbers', () => {
  expect(add(2, 3)).toBe(5);
});
Enter fullscreen mode Exit fullscreen mode

Key characteristics:

  • Fast - unit tests run in milliseconds since there's no network, database, or filesystem involved.
  • Isolated - dependencies are mocked or stubbed out, so you're only testing your logic, not anyone else's.
  • Cheap to write and maintain - because the scope is small, tests are easy to write and pinpoint failures precisely.

What it's good for:

Catching logic errors early off-by-one bugs, incorrect conditionals, bad edge-case handling right where they happen.

What it's NOT good for:

Unit tests won't tell you if your function actually works correctly when wired up to a real database, API, or another module. All your mocks could be lying to you.

Popular tools:

  • JavaScript/TypeScript: Jest, Vitest, Mocha
  • Python: pytest, unittest
  • Java: JUnit, TestNG
  • Go: the built-in testing package
  • Ruby: RSpec, Minitest

Common mistakes with unit testing:

  • Testing implementation instead of behavior. If you refactor a function's internals without changing its output and the test breaks, the test was too tightly coupled to implementation details.
  • Over-mocking. Mocking so much that the test essentially just checks that your mocks were called not that your logic actually works.
  • Chasing coverage numbers. 100% coverage doesn't mean 100% correctness. You can cover every line and still miss the one edge case that matters.
  • Testing trivial code. Writing a unit test for a one-line getter/setter adds maintenance cost without adding much safety.

2. Integration Testing

Integration tests check that multiple units or your code and an external system work together correctly.

What it looks like:

test('saves a new user to the database', async () => {
  const user = await createUser({ name: 'Alice', email: 'alice@example.com' });
  const found = await db.users.findById(user.id);
  expect(found.name).toBe('Alice');
});
Enter fullscreen mode Exit fullscreen mode

Key characteristics:

  • Slower than unit tests - they typically touch a real (or realistic test) database, API, or filesystem.
  • Fewer mocks - the whole point is to test the real interaction, not a simulated one.
  • Catches "seam" bugs - issues that only appear when components are connected, like a mismatched schema or a broken API contract.

What it's good for:

Verifying that your service layer, database layer, and third-party integrations actually cooperate the way you assume they do.

What it's NOT good for:

Testing the full user journey through your application, or verifying the UI behaves correctly.

Popular tools:

  • Testcontainers (spins up real Docker containers for databases, queues, etc. during tests)
  • Supertest (Node.js, for testing HTTP APIs)
  • pytest with a test database fixture (Python)
  • Spring Boot Test (Java)
  • Postman/Newman for API-contract style integration tests

Common mistakes with integration testing:

  • Using a fake in-memory database instead of the real one. An in-memory SQLite standing in for production Postgres will hide real differences in behavior, constraints, and query support.
  • Not cleaning up state between tests. Leftover data from one test bleeding into another causes flaky, order-dependent failures.
  • Skipping this layer entirely. Teams often jump straight from unit tests to E2E tests, leaving a blind spot where most "it works on my machine" bugs actually live.
  • Making them too broad. If an "integration test" quietly spins up your entire application and drives it through the UI, it's really an E2E test wearing a disguise and it inherits all of E2E's slowness and brittleness.

3. End-to-End (E2E) Testing

E2E tests simulate a real user interacting with your entire application, from the UI down through the backend and database.

What it looks like:

test('user can sign up and see the dashboard', async () => {
  await page.goto('https://myapp.com/signup');
  await page.fill('#email', 'alice@example.com');
  await page.fill('#password', 'secure123');
  await page.click('#submit');
  await expect(page).toHaveURL('https://myapp.com/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Key characteristics:

  • Slowest and most expensive - a full browser (or device) spins up and walks through real user flows.
  • Most realistic - it tests exactly what your users experience, across the entire stack.
  • Most brittle - small UI changes can break E2E tests even when the underlying logic is fine.

What it's good for:

Catching critical, business-blocking bugs like a broken checkout flow or a login page that silently fails before they reach production.

What it's NOT good for:

Pinpointing why something broke. A failing E2E test tells you the user journey is broken, not which line of code caused it.

Popular tools:

  • Playwright (fast, modern, great cross-browser support)
  • Cypress (developer-friendly, strong debugging experience)
  • Selenium (the old guard, still widely used, especially in enterprise)
  • Appium (for mobile app E2E testing)

Common mistakes with E2E testing:

  • Writing too many of them. E2E tests are expensive to run and maintain. A suite with hundreds of E2E tests becomes a CI bottleneck and a maintenance nightmare.
  • Testing every permutation instead of critical paths. You don't need an E2E test for every form validation message save E2E for flows where failure actually costs the business money (checkout, signup, payment).
  • Flaky tests from timing issues. Not waiting properly for async UI updates causes intermittent failures that erode trust in the whole suite. Modern tools like Playwright handle a lot of this automatically with built-in retries and auto-waiting, but it's still the #1 source of E2E flakiness.
  • Running them on every commit. E2E suites are often better run on a schedule or before deployment, not on every single push, to avoid slowing down developer feedback loops.

A Worked Example: Testing "User Signup" Three Ways

Let's make this concrete. Say you're building a signup feature with a validateEmail function, a createUser service that writes to a database, and a signup page in the UI. Here's how each layer of testing looks for that one feature.

Unit test checking the validation logic in isolation:

import { validateEmail } from './validators';

test('rejects an email without an @ symbol', () => {
  expect(validateEmail('alice.example.com')).toBe(false);
});

test('accepts a well-formed email', () => {
  expect(validateEmail('alice@example.com')).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

No database, no network, no UI just pure logic, verified in milliseconds.

Integration test checking that signup actually writes to the database correctly:

test('creates a user record with a hashed password', async () => {
  const user = await createUser({
    email: 'alice@example.com',
    password: 'plaintext123',
  });

  const stored = await db.users.findById(user.id);
  expect(stored.email).toBe('alice@example.com');
  expect(stored.password).not.toBe('plaintext123'); // should be hashed, not stored raw
});
Enter fullscreen mode Exit fullscreen mode

This confirms the service layer and the real database agree on schema, constraints, and behavior something a mocked database could easily hide.

E2E test checking the whole signup journey works for a real user:

test('a new user can sign up and land on the dashboard', async () => {
  await page.goto('https://myapp.com/signup');
  await page.fill('#email', 'alice@example.com');
  await page.fill('#password', 'secure123');
  await page.click('#submit');

  await expect(page).toHaveURL('https://myapp.com/dashboard');
  await expect(page.locator('#welcome-message')).toContainText('Welcome, alice');
});
Enter fullscreen mode Exit fullscreen mode

This is the only one of the three that would have caught my earlier bug a frontend/backend field-name mismatch because it's the only test actually exercising both sides of the wire, through the real UI.

Notice the shift in what each test is actually protecting you against:

  • The unit test protects the logic.
  • The integration test protects the contract between your code and the database.
  • The E2E test protects the user experience.

You need all three, because a bug can hide in any one of those three layers while the other two stay green.

Putting It Together: The Testing Pyramid

These three types aren't competing strategies they're complementary layers, often visualized as a pyramid:

        /\
       /E2E\          <- few, slow, high confidence
      /------\
     /Integr. \       <- more, moderate speed
    /----------\
   /   Unit     \     <- many, fast, cheap
  /--------------\
Enter fullscreen mode Exit fullscreen mode
  • Lots of unit tests at the base, giving you fast feedback on logic.
  • A moderate number of integration tests in the middle, verifying components play well together.
  • A handful of E2E tests at the top, confirming the critical user journeys actually work.

Invert this pyramid relying mostly on slow, brittle E2E tests and you get a test suite that takes forever to run and breaks constantly for the wrong reasons. Stack too heavily on unit tests alone, and you might ship code where every piece works in isolation but the system as a whole is broken.

Two Shapes to Avoid

Beyond getting the ratio right, watch out for two common anti-patterns:

The Ice Cream Cone (inverted pyramid). Teams under deadline pressure often skip unit and integration tests and lean almost entirely on E2E or manual QA testing "because it's more realistic." The result is a slow, flaky suite where a single bug can take 20 minutes to reproduce and pin down, because the failure could be anywhere in the stack.

The Hourglass. Lots of unit tests, lots of E2E tests, but almost no integration tests in the middle. This is sneaky because it looks healthy on a coverage report, but it leaves exactly the kind of "seam" bugs mismatched API contracts, broken database migrations that integration tests are designed to catch.

A Note on Other Testing Types

Unit, integration, and E2E are the three most fundamental types, but they're not the only kinds of testing you'll encounter. Depending on your project you may also need:

  • Contract testing (e.g. Pact) - verifying that a producer and consumer of an API agree on the shape of their contract, especially useful in microservices.
  • Performance/load testing (e.g. k6, JMeter) - verifying your system holds up under real-world traffic.
  • Security testing - static analysis, dependency scanning, and penetration testing.
  • Manual/exploratory testing - a human deliberately trying to break the app in ways automated tests don't anticipate.

These are valuable, but they solve different problems than the core three think of them as additional tools in the toolbox rather than replacements for the pyramid.

Quick Reference

Type Speed Scope Confidence Cost to Maintain
Unit ⚑️ Fast Single function/class Low-Medium Low
Integration 🚢 Medium Multiple components Medium-High Medium
E2E 🐒 Slow Full application High High

Closing Thoughts

No single type of testing is enough on its own. Unit tests give you speed and precision, integration tests confirm your pieces actually fit together, and E2E tests give you confidence that real users can actually use your app. The best test suites use all three deliberately lots of unit tests, a healthy layer of integration tests, and a lean set of E2E tests covering only your most critical flows.

If you're starting a new project, don't try to build the perfect pyramid on day one. Start with unit tests for your core logic, add integration tests as soon as you have a real database or external service in the picture, and add a handful of E2E tests around your one or two most business-critical flows signup, checkout, whatever would actually hurt if it silently broke. Grow the suite from there as the codebase grows, and revisit the shape of your pyramid periodically it's easy to drift into an hourglass or an ice cream cone without noticing.


πŸ“šMore Reading

Top comments (0)