
Unit vs Integration vs E2E Testing
Differences and usage of test types

Differences and usage of test types
Public APIs face unexpected traffic floods without proper protection. Rate limiting, API key management, and IP restrictions to protect your API.

Started with admin/user roles but requirements grew complex. When RBAC isn't enough, ABAC provides attribute-based fine-grained control.

With 3 services needing separate logins, SSO unified authentication. One login grants access to everything.

Password resets were half my support tickets. Passkeys eliminate passwords entirely, but implementation is more complex than expected.

When I first built my service, I didn't write a single test. I thought, "What matters is shipping fast, I'll write tests later." But one day, I modified a small feature, and a completely unrelated feature broke. I only found out when a user reported it. That's when I realized, "Oh no, this is how services die."
So I started writing tests, but then another problem arose. I had no idea what tests to write or how. Unit tests, integration tests, E2E tests... I'd heard the terms, but I was clueless about what to actually test.
So I learned by doing. Failing, fixing, and trying again. Here's what I learned through that process.
Unit tests mean testing individual functions or classes in isolation. At first, I didn't understand why this was necessary. I thought, "What difference does testing one function make?"
But then this happened. I created a price calculation function, and there was a bug in the discount logic. I only discovered it when a user tried to checkout and got a weird price, then contacted support. Totally embarrassing.
After that, I started writing unit tests for critical logic like price calculations:
// Price calculation function
function calculatePrice(basePrice, discountRate) {
if (discountRate < 0 || discountRate > 100) {
throw new Error('Discount rate must be between 0-100');
}
return basePrice * (1 - discountRate / 100);
}
// Unit tests
describe('calculatePrice', () => {
test('normal discount calculation', () => {
expect(calculatePrice(10000, 10)).toBe(9000);
});
test('0% discount', () => {
expect(calculatePrice(10000, 0)).toBe(10000);
});
test('100% discount', () => {
expect(calculatePrice(10000, 100)).toBe(0);
});
test('invalid discount rate throws error', () => {
expect(() => calculatePrice(10000, -10)).toThrow();
expect(() => calculatePrice(10000, 150)).toThrow();
});
});
Having these tests gave me confidence. When modifying price calculation logic later, I could just run the tests and immediately confirm, "Okay, this change didn't break existing functionality."
The key to unit tests is they're fast and independent. No database needed, no API calls, just execute one function and check the result. That's why you can run hundreds of unit tests in seconds.
Writing only unit tests led to another problem. Each function worked fine individually, but when combined, issues arose. For example:
But when a user actually signed up, data was saved but the email wasn't sent. Because I forgot to call the email function. Unit tests couldn't catch this.
That's when I needed integration tests. Testing multiple modules working together:
describe('User registration flow', () => {
test('signup saves to DB + sends email', async () => {
// Call actual service logic
const result = await userService.register({
email: 'test@example.com',
password: 'password123',
name: 'John Doe'
});
// Check if saved to DB
const savedUser = await db.users.findOne({
email: 'test@example.com'
});
expect(savedUser).toBeDefined();
expect(savedUser.name).toBe('John Doe');
// Check if email was sent
const sentEmails = await emailService.getSentEmails();
expect(sentEmails).toContainEqual(
expect.objectContaining({
to: 'test@example.com',
subject: expect.stringContaining('Welcome')
})
);
});
});
What I learned from integration tests is they use actual dependencies. While unit tests mock the database, integration tests use a real test database. Not production DB, but a test one.
So they're slower than unit tests. But the big advantage is confirming "Do these features actually work together?"
With integration tests in place, I felt much more confident. But then another problem appeared. Backend logic worked fine, but the frontend button was connected wrong, so signup didn't work.
For example:
/api/register ✅/api/signup ❌Different URLs caused 404 errors, and integration tests couldn't catch this because they only tested the backend.
That's when I needed E2E (End-to-End) tests. Testing the entire process of a real user clicking, typing, and submitting in a browser:
describe('Signup E2E', () => {
test('User fills signup form and submits', async () => {
// 1. Navigate to signup page
await page.goto('https://myservice.com/signup');
// 2. Fill form
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.fill('[name=name]', 'John Doe');
// 3. Click submit button
await page.click('button[type=submit]');
// 4. Check success message
await expect(page.locator('.success-message')).toBeVisible();
await expect(page).toHaveURL('/welcome');
// 5. Verify can actually login
await page.goto('https://myservice.com/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
});
});
When I first wrote E2E tests, I realized they're from a real user perspective. Don't care how the code works internally, just verify "if user does this, this should happen."
But E2E tests are really slow. Actually launching a browser, loading pages, clicking... each test takes several seconds. And sometimes they fail randomly. If the network is slow or page loading is delayed.
After writing all three types of tests, I learned something important. You can't test everything with E2E. E2E is slow and unstable. But if you only write unit tests, you miss integration issues.
That's when I learned about the "test pyramid" concept:
/\
/E2E\ ← Few (slow, expensive)
/------\
/Integration\ ← Moderate (medium speed)
/----------\
/Unit Tests \ ← Many (fast, cheap)
/--------------\
In my experience, this works well:
Unit Tests (70-80%)Initially, I wrote vague test names like test('works'). But when tests failed later, I couldn't tell which test failed or why.
Now I write like this:
// ❌ Bad
test('works', () => { ... });
// ✅ Good
test('throws error when discount rate is negative', () => { ... });
test('sends welcome email when user registers', () => { ... });
Test code is as important as production code. Lots of duplication makes maintenance hard. So I extract common logic into helper functions:
// Common test helper
function createTestUser(overrides = {}) {
return {
email: 'test@example.com',
password: 'password123',
name: 'Test User',
...overrides
};
}
// Reuse in multiple tests
test('email duplicate check', async () => {
const user = createTestUser();
await userService.register(user);
await expect(userService.register(user)).rejects.toThrow('already exists');
});
Initially, I made tests depend on each other. Like "Test B uses data created by Test A." But when Test A failed, Test B failed too.
Now I make each test independent. Prepare needed data in beforeEach, clean up in afterEach:
describe('User service', () => {
beforeEach(async () => {
// Reset DB before each test
await db.users.deleteMany({});
});
test('signup', async () => {
// This test works independently
});
test('login', async () => {
// This test is also independent
});
});
Unit tests verify individual function correctness, integration tests verify module collaboration, and E2E tests verify the entire user experience. Following the test pyramid with many unit tests, moderate integration tests, and minimal E2E tests creates a fast yet reliable test suite. It's tedious at first, but once you get used to it, you'll feel so confident that you can't write code without tests.