Software Testing Explained — Unit, Integration, and E2E Testing Guide
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
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:
- Red: Write a failing test
- Green: Write the minimum code to make it pass
- 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 dashboardIn 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
Try It Yourself
- Pick a simple function you’ve written recently
- Write 3 unit tests for it — happy path, error case, and edge case
- Run the tests using Jest or your framework of choice
- 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