Skip to content
Software Testing Explained — Unit, Integration, and E2E Testing Guide

Software Testing Explained — Unit, Integration, and E2E Testing Guide

DodaTech Updated Jun 7, 2026 9 min read

Software testing is the process of verifying that a program behaves as expected by running it against predefined cases, catching bugs before users do, and ensuring changes don’t break existing functionality.

What You’ll Learn

By the end of this tutorial, you’ll understand the differences between unit, integration, and end-to-end tests, know when to use each type, grasp TDD and BDD approaches, and be able to apply the test pyramid to structure your test suites effectively.

Why Software Testing Matters

Without tests, every code change is a gamble. You fix one bug and introduce three others. In production, that costs money and user trust. At DodaTech, every release of Durga Antivirus Pro runs through thousands of automated tests before reaching users — false positives in antivirus software aren’t just annoying, they’re dangerous.

Software Testing Learning Path

    flowchart LR
  A[Testing Basics] --> B[Jest]
  A --> C[Testing Library]
  B --> D[Playwright]
  B --> E[Cypress]
  A --> F{You Are Here}
  style F fill:#f90,color:#fff
  
Prerequisites: Basic programming knowledge in JavaScript or Python. No testing experience needed.

Unit vs Integration vs E2E Tests

Think of testing like inspecting a car. Unit tests check each individual part — does this spark plug fire correctly? Integration tests check that parts work together — does the engine connect to the transmission? End-to-end tests drive the whole car — does it start, steer, and stop properly on a real road?

Unit Tests

Unit tests verify the smallest isolatable piece of code — a single function, method, or module — in isolation from the rest of the system.

Purpose: Catch logic errors early, document expected behavior, and enable safe refactoring.

Characteristics: Fast (milliseconds), no external dependencies (no database, no network), and run on every save.

// math.js — a simple utility
function add(a, b) {
  return a + b;
}

function divide(a, b) {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

module.exports = { add, divide };

// math.test.js
const { add, divide } = require('./math');

test('adds 2 + 3 to equal 5', () => {
  expect(add(2, 3)).toBe(5);
});

test('divide 10 by 2 to equal 5', () => {
  expect(divide(10, 2)).toBe(5);
});

test('throws on division by zero', () => {
  expect(() => divide(5, 0)).toThrow('Division by zero');
});

Expected output:

PASS  ./math.test.js
  ✓ adds 2 + 3 to equal 5 (2 ms)
  ✓ divide 10 by 2 to equal 5 (1 ms)
  ✓ throws on division by zero (1 ms)

Integration Tests

Integration tests verify that multiple units work together correctly. The database writes what the API expects. The service layer passes the right data to the repository.

Purpose: Catch mismatches between components — wrong data shapes, missing fields, incorrect error propagation.

// userService.js
const db = require('./db');

async function createUser(name, email) {
  const existing = await db.findUserByEmail(email);
  if (existing) throw new Error('Email already registered');
  return db.insertUser({ name, email, createdAt: new Date() });
}

// userService.test.js
const { createUser } = require('./userService');
const db = require('./db');

jest.mock('./db');

test('creates a new user when email is not taken', async () => {
  db.findUserByEmail.mockResolvedValue(null);
  db.insertUser.mockResolvedValue({ id: 1, name: 'Alice', email: 'alice@test.com' });

  const user = await createUser('Alice', 'alice@test.com');
  expect(user.name).toBe('Alice');
  expect(db.insertUser).toHaveBeenCalledTimes(1);
});

test('rejects duplicate email', async () => {
  db.findUserByEmail.mockResolvedValue({ id: 1 });
  await expect(createUser('Bob', 'alice@test.com')).rejects.toThrow('Email already registered');
});

Expected output:

PASS  ./userService.test.js
  ✓ creates a new user when email is not taken (5 ms)
  ✓ rejects duplicate email (2 ms)

End-to-End Tests

E2E tests simulate real user interactions in a browser — clicking buttons, filling forms, navigating pages.

Purpose: Verify the entire system works from the user’s perspective. Catches deployment issues, missing assets, and integration failures that unit tests miss.

// login.e2e.js (Playwright example)
const { test, expect } = require('@playwright/test');

test('user can log in successfully', async ({ page }) => {
  await page.goto('https://example.com/login');
  await page.fill('#email', 'user@example.com');
  await page.fill('#password', 'secret123');
  await page.click('button[type="submit"]');
  await expect(page.locator('.welcome')).toHaveText('Welcome back, User!');
});

Expected output (no visible output — test passes silently unless a step fails):

Running 1 test using Chromium...
  ✓ user can log in successfully (2.3s)

The Test Pyramid

    flowchart TD
  subgraph Test Pyramid
    direction TB
    U[Unit Tests<br/>Many, fast, cheap] --> I[Integration Tests<br/>Medium count, medium speed]
    I --> E[E2E Tests<br/>Few, slow, expensive]
  end
  

The test pyramid is a guideline: write lots of unit tests (cover edge cases and logic), a moderate number of integration tests (cover critical paths), and a few end-to-end tests (cover core user journeys). This gives you the best return on investment — fast feedback from unit tests and confidence from E2E tests where it matters most.

TDD vs BDD

TDD (Test-Driven Development)

Write the test before the implementation. Red-Green-Refactor cycle:

  1. Red: Write a failing test
  2. Green: Write the minimum code to make it pass
  3. Refactor: Clean up the code while keeping tests green
// TDD cycle example
test('returns "Fizz" for multiples of 3', () => {
  expect(fizzBuzz(3)).toBe('Fizz');
});

test('returns "Buzz" for multiples of 5', () => {
  expect(fizzBuzz(5)).toBe('Buzz');
});

test('returns "FizzBuzz" for multiples of 3 and 5', () => {
  expect(fizzBuzz(15)).toBe('FizzBuzz');
});

test('returns the number as string otherwise', () => {
  expect(fizzBuzz(2)).toBe('2');
});

BDD (Behavior-Driven Development)

BDD extends TDD by writing tests in natural language that non-developers can read. Frameworks like Cucumber use Gherkin syntax:

Feature: Login
  Scenario: Successful login
    Given I am on the login page
    When I enter valid credentials
    Then I should see the dashboard

In JavaScript, BDD-style tests look similar to TDD but focus on behavior rather than implementation:

describe('Shopping Cart', () => {
  it('should calculate the total of all items', () => {
    const cart = new ShoppingCart();
    cart.addItem({ name: 'Book', price: 15 });
    cart.addItem({ name: 'Pen', price: 3 });
    expect(cart.total()).toBe(18);
  });

  it('should apply discount code', () => {
    const cart = new ShoppingCart();
    cart.addItem({ name: 'Book', price: 20 });
    cart.applyDiscount('SAVE10');
    expect(cart.total()).toBe(18);
  });
});

The Test-Driven Workflow

    flowchart LR
  A[Write Failing Test] --> B[Run Test - Red]
  B --> C[Write Minimum Code]
  C --> D[Run Test - Green]
  D --> E[Refactor Code]
  E --> A
  

Each cycle is short — usually 1–5 minutes. This keeps you focused and ensures every line of code has a reason to exist.

Common Testing Mistakes

1. Testing Implementation Details Instead of Behavior

Testing private methods or internal state makes tests brittle — they break when you refactor, even if the behavior stays the same.

Fix: Test public APIs and observable behavior, not internal implementation.

2. Writing Too Many E2E Tests

E2E tests are slow and flaky. A suite of 500 E2E tests might take an hour to run. Follow the test pyramid: write most tests at the unit level.

Fix: Reserve E2E tests for critical user journeys (login, checkout, signup). Use integration tests for everything else.

3. Not Running Tests in CI

If tests only run on your machine, they’re not protecting anyone. Untested code reaches production.

Fix: Integrate tests into your CI/CD pipeline so every PR is verified automatically.

4. Flaky Tests That Pass Sometimes

A test that fails intermittently erodes trust. Teams learn to ignore failing tests, which defeats the purpose.

Fix: Investigate flaky tests immediately. Common causes: shared mutable state, race conditions, network timeouts.

5. Writing Tests After the Fact (and Skipping Them)

“Let’s write tests later” almost never happens. Without tests, refactoring becomes terrifying.

Fix: Adopt TDD or at minimum write tests alongside the code — not after.

6. Mocking Everything

Mocking every dependency creates tests that pass but don’t verify anything real. You end up testing that your mocks work correctly.

Fix: Only mock external boundaries (network, filesystem, database). Use real objects for internal dependencies when possible.

7. Ignoring Edge Cases

Testing only the happy path misses null inputs, empty arrays, network errors, and unexpected data shapes.

Fix: Use property-based testing or at minimum test: empty state, error state, boundary values, and invalid input.

Practice Questions

1. What are the three main types of tests in the test pyramid?

Unit tests (fast, isolated, many), integration tests (verify component interaction, medium count), and end-to-end tests (simulate real user flows, few).

2. What does “Red-Green-Refactor” mean in TDD?

Red: write a failing test. Green: write the minimum code to pass. Refactor: clean up without changing behavior.

3. When should you use a mock vs a real object?

Mock external dependencies that are slow or non-deterministic (database, network, filesystem). Use real objects for in-process dependencies where speed isn’t an issue.

4. Why is testing implementation details a bad practice?

Because refactoring the implementation breaks the tests even when behavior hasn’t changed. Tests should verify what the code does, not how it does it.

5. Challenge: Build a test suite for a password validator.

Write a function validatePassword(password) that requires at least 8 characters, one uppercase letter, one number, and one special character. Test: valid password, missing uppercase, missing number, missing special char, too short, and empty string.

Mini Project: Test Coverage Analyzer

// coverageAnalyzer.js
// Analyze test coverage across different test types

function analyzeCoverage(testReport) {
  const { unit, integration, e2e } = testReport;
  const total = unit.count + integration.count + e2e.count;

  return {
    totalTests: total,
    distribution: {
      unit: ((unit.count / total) * 100).toFixed(1) + '%',
      integration: ((integration.count / total) * 100).toFixed(1) + '%',
      e2e: ((e2e.count / total) * 100).toFixed(1) + '%',
    },
    passRate: {
      unit: ((unit.passed / unit.count) * 100).toFixed(1) + '%',
      integration: ((integration.passed / integration.count) * 100).toFixed(1) + '%',
      e2e: ((e2e.passed / e2e.count) * 100).toFixed(1) + '%',
    },
    pyramidHealth: unit.count > (integration.count + e2e.count) ? 'healthy' : 'too few unit tests',
    suggestion: unit.count === 0 ? 'Start adding unit tests for core logic' : '',
  };
}

const myReport = {
  unit: { count: 120, passed: 118 },
  integration: { count: 30, passed: 29 },
  e2e: { count: 8, passed: 7 },
};

console.log(JSON.stringify(analyzeCoverage(myReport), null, 2));

Expected output:

{
  "totalTests": 158,
  "distribution": {
    "unit": "75.9%",
    "integration": "19.0%",
    "e2e": "5.1%"
  },
  "passRate": {
    "unit": "98.3%",
    "integration": "96.7%",
    "e2e": "87.5%"
  },
  "pyramidHealth": "healthy",
  "suggestion": ""
}

FAQ

What’s the difference between TDD and BDD?
TDD is a developer practice where tests drive the design of code. BDD extends TDD by writing tests in natural language (Given/When/Then) that stakeholders can read, focusing on behavior rather than implementation.
How many tests should I write?
Follow the test pyramid ratio: roughly 70% unit, 20% integration, 10% E2E. The exact numbers depend on your project, but unit tests should always outnumber E2E tests significantly.
Are 100% code coverage necessary?
No. 100% coverage doesn’t mean 100% bug-free — it means every line runs, not that every scenario is verified. Focus on critical paths and edge cases. 80–90% coverage on core modules is a better target than 100% everywhere.
What makes a good unit test?
A good unit test is fast, isolated (no network/database), deterministic (same result every run), focused on one behavior, and has a clear name describing what it verifies.
Should I test private methods?
No. Test the public API that uses private methods. If a private method has complex logic, extract it into its own module where it becomes a public API that can be tested directly.

Try It Yourself

  1. Pick a simple function you’ve written recently
  2. Write 3 unit tests for it — happy path, error case, and edge case
  3. Run the tests using Jest or your framework of choice
  4. Watch them fail first (red), then write code to pass (green), then clean up (refactor)

What’s Next

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro