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)
-
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.
-
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.
-
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)
-
Integration Issues
// E2E test catches: Database connection fails, wrong connection string
// Unit test can't catch: Real database interaction problems -
Configuration Errors
// E2E test catches: Wrong Stripe API key, missing environment variable
// Unit test can't catch: Runtime configuration issues -
Authentication Flow
// E2E test catches: JWT token validation fails, Cognito integration broken
// Unit test mocks authentication, so misses real issues -
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
Recommended Distribution
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:
- Developer writes code
- Unit test fails immediately (5ms)
- Developer fixes bug
- Unit test passes
- Total time: 30 seconds
Without Unit Tests (Only E2E):
- Developer writes code
- Runs E2E test (10 seconds)
- E2E test fails
- Debug through entire stack (5 minutes)
- Find bug in calculation logic
- Fix bug
- Re-run E2E test (10 seconds)
- Total time: 5+ minutes
With Both:
- Unit test catches bug immediately (5ms)
- Fix bug
- Unit test passes
- E2E test validates integration (10 seconds)
- Total time: 30 seconds
Conclusion
You need both unit tests and E2E tests because:
- Unit tests = Fast feedback, catch bugs early, enable refactoring
- 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!
Recommended Approach for FishingLog
-
Start with Unit Tests (60-70%)
- Business logic (calculations, validations)
- Services (with mocked dependencies)
- Helpers and utilities
-
Add Integration Tests (20-30%)
- API endpoints with real database
- Service integrations
- Authentication flows
-
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!