Skip to main content

Testing Pyramid: Why You Need Both Unit and E2E Tests

The Short Answer

Yes, you absolutely still need unit tests even with E2E tests. They serve different purposes and catch different types of bugs.

The Testing Pyramid

        /\
/ \ E2E Tests (10%)
/----\ - Slow, expensive, few
/ \ - Test entire system
/--------\
/ \ Integration Tests (30%)
/------------\ - Medium speed, moderate cost
/ \ - Test component interactions
/----------------\ Unit Tests (60%)
\----------------/ - Fast, cheap, many
- Test individual components

Why You Need Both

Unit Tests: Fast Feedback Loop

What they do:

  • Test individual functions, methods, classes in isolation
  • Run in milliseconds
  • Can run thousands of tests in seconds
  • Catch bugs early in development

What they catch:

  • Logic errors in business rules
  • Edge cases in calculations
  • Validation failures
  • Data transformation bugs
  • Algorithm correctness

Example:

[Fact]
public void CalculatePersonalBest_WhenNewCatchIsLarger_UpdatesPB()
{
// Arrange
var existingPB = new PersonalBest { LengthCm = 50 };
var newCatch = new Catch { LengthCm = 60 };

// Act
var result = PersonalBestCalculator.UpdateIfBetter(existingPB, newCatch);

// Assert
Assert.Equal(60, result.LengthCm);
Assert.True(result.IsUpdated);
}

Benefits:

  • ✅ Run on every save (via file watchers)
  • ✅ Instant feedback during development
  • ✅ Help with refactoring confidence
  • ✅ Document expected behavior
  • ✅ Catch bugs before they reach E2E tests

E2E Tests: System Validation

What they do:

  • Test entire user journeys across all layers
  • Run in seconds/minutes
  • Test real integrations (database, external APIs)
  • Validate the system works as users expect

What they catch:

  • Integration issues between components
  • Configuration problems
  • Database schema mismatches
  • Authentication/authorization bugs
  • End-to-end workflow failures

Example:

[Fact]
public async Task User_CanRegisterForTournament_WithPayment()
{
// This tests: API → Database → Stripe → Webhook → Database
var response = await _client.PostAsJsonAsync(
$"/api/tournament/{tournamentId}/register",
new { paymentMethodId = "pm_test_123" });

response.EnsureSuccessStatusCode();
// Verify registration in database
// Verify payment intent created
// Verify webhook processed
}

Limitations:

  • ❌ Slow (seconds per test)
  • ❌ Expensive (requires full infrastructure)
  • ❌ Hard to debug (many moving parts)
  • ❌ Can't test all edge cases (too slow)
  • ❌ Brittle (break when UI changes)

What Each Type Catches That the Other Doesn't

Unit Tests Catch (That E2E Misses)

  1. Business Logic Bugs

    // Unit test catches this immediately
    [Fact]
    public void CalculateTournamentScore_WithInvalidWeights_ThrowsException()
    {
    Assert.Throws<ArgumentException>(() =>
    TournamentCalculator.CalculateScore(null, weights));
    }

    E2E test might pass if the error is handled gracefully, but you want to catch it at the source.

  2. Edge Cases

    // Test 100 edge cases in 1 second
    [Theory]
    [InlineData(-1, false)]
    [InlineData(0, false)]
    [InlineData(1, true)]
    [InlineData(100, true)]
    [InlineData(101, false)]
    public void ValidateCatchLength_WithVariousInputs_ReturnsCorrectResult(int length, bool expected)
    {
    var result = CatchValidator.IsValidLength(length);
    Assert.Equal(expected, result);
    }

    E2E tests can't efficiently test all edge cases.

  3. Performance Issues

    [Fact]
    public void FilterLogEntries_WithLargeDataset_CompletesInUnder100ms()
    {
    var entries = GenerateLargeDataset(10000);
    var stopwatch = Stopwatch.StartNew();
    var result = LogEntryFilter.Filter(entries, criteria);
    stopwatch.Stop();

    Assert.True(stopwatch.ElapsedMilliseconds < 100);
    }

    E2E tests are too slow to catch micro-optimizations.

E2E Tests Catch (That Unit Tests Miss)

  1. Integration Issues

    // E2E test catches: Database connection fails, wrong connection string
    // Unit test can't catch: Real database interaction problems
  2. Configuration Errors

    // E2E test catches: Wrong Stripe API key, missing environment variable
    // Unit test can't catch: Runtime configuration issues
  3. Authentication Flow

    // E2E test catches: JWT token validation fails, Cognito integration broken
    // Unit test mocks authentication, so misses real issues
  4. Cross-Component Bugs

    // E2E test catches: Payment succeeds but webhook doesn't update database
    // Unit test tests components separately, misses integration bugs

Real-World Example: Payment Flow

Unit Tests (Fast, Many)

// Test payment calculation logic
[Fact] public void CalculateTournamentFee_WithEarlyBird_AppliesDiscount() { }
[Fact] public void CalculateTournamentFee_WithMemberDiscount_AppliesBoth() { }
[Fact] public void CalculateTournamentFee_WithInvalidInput_ThrowsException() { }

// Test validation
[Fact] public void ValidatePaymentAmount_WithNegative_ReturnsFalse() { }
[Fact] public void ValidatePaymentAmount_WithZero_ReturnsFalse() { }
[Fact] public void ValidatePaymentAmount_WithValidAmount_ReturnsTrue() { }

// Test Stripe service (mocked)
[Fact] public async Task CreatePaymentIntent_WithValidData_ReturnsClientSecret() { }
[Fact] public async Task CreatePaymentIntent_WithStripeError_ThrowsException() { }

Time: ~50ms for all tests
Catches: Logic bugs, edge cases, validation issues

E2E Test (Slow, Few)

[Fact]
public async Task User_CanRegisterForTournament_WithPayment()
{
// Tests: API → Database → Stripe → Webhook → Database
// 1. User calls registration endpoint
// 2. Payment intent created
// 3. User completes payment
// 4. Webhook received
// 5. Registration confirmed
}

Time: ~5-10 seconds
Catches: Integration issues, configuration problems, end-to-end workflow

Cost-Benefit Analysis

Unit Tests

Cost:

  • ⏱️ Write: 5-10 minutes per test
  • ⏱️ Run: Milliseconds
  • 💰 Infrastructure: None (just code)

Benefit:

  • ✅ Catch bugs immediately during development
  • ✅ Enable confident refactoring
  • ✅ Document expected behavior
  • ✅ Run on every save

ROI: Very High - Write once, run thousands of times

E2E Tests

Cost:

  • ⏱️ Write: 30-60 minutes per test
  • ⏱️ Run: Seconds to minutes
  • 💰 Infrastructure: Database, external services, CI/CD time

Benefit:

  • ✅ Validate system works end-to-end
  • ✅ Catch integration bugs
  • ✅ Test user workflows

ROI: High, but expensive - Use sparingly for critical paths

Best Practice: The Right Mix

Unit Tests:        60-70%  (Fast, catch most bugs)
Integration Tests: 20-30% (Medium speed, test interactions)
E2E Tests: 10% (Slow, test critical paths)

What to Unit Test

DO Unit Test:

  • Business logic and calculations
  • Validation rules
  • Data transformations
  • Algorithms
  • Utility functions
  • Edge cases
  • Error handling logic

DON'T Unit Test:

  • Framework code (ASP.NET Core, EF Core)
  • Third-party libraries
  • Simple getters/setters
  • Configuration (test in integration tests)

What to E2E Test

DO E2E Test:

  • Critical user journeys
  • Payment flows
  • Authentication flows
  • Cross-system integrations
  • Happy paths (not all edge cases)

DON'T E2E Test:

  • Every edge case (too slow)
  • Simple CRUD operations (unit test these)
  • Internal business logic (unit test these)
  • Performance micro-optimizations

Example: Testing a Tournament Registration Feature

Unit Tests (Write First)

// Business Logic
[Fact] public void CalculateRegistrationFee_EarlyBird_AppliesDiscount() { }
[Fact] public void CalculateRegistrationFee_Member_AppliesDiscount() { }
[Fact] public void ValidateRegistration_TournamentFull_ReturnsFalse() { }
[Fact] public void ValidateRegistration_UserAlreadyRegistered_ReturnsFalse() { }

// Validation
[Fact] public void ValidateRegistrationRequest_MissingTournamentId_ThrowsException() { }
[Fact] public void ValidateRegistrationRequest_InvalidTournamentId_ThrowsException() { }

// Service Layer (Mocked Dependencies)
[Fact] public async Task CreateRegistration_WithValidData_CreatesRegistration() { }
[Fact] public async Task CreateRegistration_DatabaseError_ThrowsException() { }

Coverage: All business logic, edge cases, error scenarios
Time: ~100ms for all tests
Run: On every code change

Integration Test (Write Second)

[Fact]
public async Task TournamentController_Register_WithValidRequest_CreatesRegistration()
{
// Tests: Controller → Service → Database
// Uses real database (TestContainers)
var response = await _client.PostAsJsonAsync(
$"/api/tournament/{tournamentId}/register",
request);

response.EnsureSuccessStatusCode();
// Verify in database
}

Coverage: API endpoint, database interaction
Time: ~500ms
Run: Before commits

E2E Test (Write Last)

[Fact]
public async Task User_CanRegisterForTournament_WithPayment_EndToEnd()
{
// Tests: Frontend → API → Database → Stripe → Webhook → Database
// Full user journey
await page.goto('/tournaments');
await page.click('[data-testid="tournament-card"]');
await page.click('[data-testid="register-button"]');
// ... complete payment flow
await expect(page.locator('[data-testid="registration-confirmed"]')).toBeVisible();
}

Coverage: Complete user journey
Time: ~10 seconds
Run: Before merges, in CI/CD

The Development Workflow

1. Write Unit Tests First (TDD)

// Write test
[Fact]
public void CalculateFee_WithDiscount_AppliesCorrectly()
{
var result = FeeCalculator.Calculate(100, 0.1);
Assert.Equal(90, result);
}

// Write code to make it pass
public class FeeCalculator
{
public static decimal Calculate(decimal amount, decimal discount)
{
return amount * (1 - discount);
}
}

Benefit: Think through logic before implementing

2. Write Integration Tests

[Fact]
public async Task TournamentController_Register_CreatesRegistration()
{
// Test API endpoint with real database
}

Benefit: Catch integration issues early

3. Write E2E Tests for Critical Paths

[Fact]
public async Task User_CanRegisterForTournament_EndToEnd()
{
// Test complete user journey
}

Benefit: Validate system works as users expect

What Happens Without Unit Tests?

Scenario: Bug in Payment Calculation

With Unit Tests:

  1. Developer writes code
  2. Unit test fails immediately (5ms)
  3. Developer fixes bug
  4. Unit test passes
  5. Total time: 30 seconds

Without Unit Tests (Only E2E):

  1. Developer writes code
  2. Runs E2E test (10 seconds)
  3. E2E test fails
  4. Debug through entire stack (5 minutes)
  5. Find bug in calculation logic
  6. Fix bug
  7. Re-run E2E test (10 seconds)
  8. Total time: 5+ minutes

With Both:

  1. Unit test catches bug immediately (5ms)
  2. Fix bug
  3. Unit test passes
  4. E2E test validates integration (10 seconds)
  5. Total time: 30 seconds

Conclusion

You need both unit tests and E2E tests because:

  1. Unit tests = Fast feedback, catch bugs early, enable refactoring
  2. E2E tests = Validate system works, catch integration bugs

Think of it like:

  • Unit tests = Checking each ingredient before cooking
  • E2E tests = Tasting the final dish

You need both to ensure quality!

  1. Start with Unit Tests (60-70%)

    • Business logic (calculations, validations)
    • Services (with mocked dependencies)
    • Helpers and utilities
  2. Add Integration Tests (20-30%)

    • API endpoints with real database
    • Service integrations
    • Authentication flows
  3. Add E2E Tests (10%)

    • Critical user journeys
    • Payment flows
    • Cross-platform flows

This gives you:

  • ✅ Fast feedback during development (unit tests)
  • ✅ Confidence in integrations (integration tests)
  • ✅ Validation of user experience (E2E tests)

Remember: Unit tests catch bugs early and cheaply. E2E tests validate the system works. You need both!