Skip to content

Testing Error Handling

Testing error handling is crucial for building reliable applications. Good error handling tests not only verify that errors are caught and handled correctly but also ensure that error contexts, metadata, and performance characteristics meet your requirements. Let's explore how to effectively test error handling using ewrap.

Understanding Error Testing

Testing error handling requires a different mindset from testing normal application flow. We need to verify not just that errors are caught, but that they carry the right information, perform efficiently, and integrate properly with the rest of our system. Let's break this down into manageable pieces.

Unit Testing Error Handling

Let's start with basic unit tests that verify error creation and handling:

func TestErrorCreation(t *testing.T) {
    // We'll create a structured test to verify different aspects of error creation
    testCases := []struct {
        name           string
        message        string
        errorType     ErrorType
        severity      Severity
        metadata      map[string]interface{}
        expectedStack bool
    }{
        {
            name:       "Basic Error",
            message:    "something went wrong",
            errorType:  ErrorTypeUnknown,
            severity:   SeverityError,
            metadata:   nil,
            expectedStack: true,
        },
        {
            name:       "Database Error with Metadata",
            message:    "connection failed",
            errorType:  ErrorTypeDatabase,
            severity:   SeverityCritical,
            metadata: map[string]interface{}{
                "host": "localhost",
                "port": 5432,
            },
            expectedStack: true,
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // Create the error with test case parameters
            err := ewrap.New(tc.message,
                ewrap.WithContext(context.Background(), tc.errorType, tc.severity))

            // Add metadata if provided
            if tc.metadata != nil {
                for k, v := range tc.metadata {
                    err = err.WithMetadata(k, v)
                }
            }

            // Verify error properties
            if err.Error() != tc.message {
                t.Errorf("Expected message %q, got %q", tc.message, err.Error())
            }

            // Verify stack trace presence
            if tc.expectedStack && err.Stack() == "" {
                t.Error("Expected stack trace, but none was captured")
            }

            // Verify metadata
            if tc.metadata != nil {
                for k, v := range tc.metadata {
                    if mv, ok := err.GetMetadata(k); !ok || mv != v {
                        t.Errorf("Metadata %q: expected %v, got %v", k, v, mv)
                    }
                }
            }
        })
    }
}

Testing Error Wrapping

Error wrapping requires special attention to ensure context is preserved:

func TestErrorWrapping(t *testing.T) {
    // Create a mock logger to verify logging behavior
    mockLogger := NewMockLogger(t)

    // Create a base error
    baseErr := errors.New("base error")

    // Create our wrapped error
    wrappedErr := ewrap.Wrap(baseErr, "operation failed",
        ewrap.WithLogger(mockLogger),
        ewrap.WithContext(context.Background(), ErrorTypeDatabase, SeverityCritical))

    // Test error chain
    t.Run("Error Chain", func(t *testing.T) {
        // Verify the complete error message
        expectedMsg := "operation failed: base error"
        if wrappedErr.Error() != expectedMsg {
            t.Errorf("Expected message %q, got %q", expectedMsg, wrappedErr.Error())
        }

        // Verify we can unwrap to the original error
        if !errors.Is(wrappedErr, baseErr) {
            t.Error("Wrapped error should match the original error")
        }

        // Verify the error chain is preserved
        cause := wrappedErr.Unwrap()
        if cause != baseErr {
            t.Error("Unwrapped error should be the base error")
        }
    })

    // Test context preservation
    t.Run("Context Preservation", func(t *testing.T) {
        ctx := getErrorContext(wrappedErr)

        if ctx.Type != ErrorTypeDatabase {
            t.Errorf("Expected error type %v, got %v", ErrorTypeDatabase, ctx.Type)
        }

        if ctx.Severity != SeverityCritical {
            t.Errorf("Expected severity %v, got %v", SeverityCritical, ctx.Severity)
        }
    })
}

Testing Error Groups

Error groups require testing both individual operations and concurrent behavior:

func TestErrorGroup(t *testing.T) {
    // Test pool creation and basic operations
    t.Run("Basic Operations", func(t *testing.T) {
        pool := ewrap.NewErrorGroupPool(4)
        eg := pool.Get()
        defer eg.Release()

        // Add some errors
        eg.Add(ewrap.New("error 1"))
        eg.Add(ewrap.New("error 2"))

        // Verify error count
        if !eg.HasErrors() {
            t.Error("Expected errors in group")
        }

        // Verify error message format
        errMsg := eg.Error()
        if !strings.Contains(errMsg, "error 1") || !strings.Contains(errMsg, "error 2") {
            t.Error("Error message doesn't contain all errors")
        }
    })

    // Test concurrent operations
    t.Run("Concurrent Operations", func(t *testing.T) {
        pool := ewrap.NewErrorGroupPool(4)
        eg := pool.Get()
        defer eg.Release()

        var wg sync.WaitGroup
        for i := 0; i < 100; i++ {
            wg.Add(1)
            go func(i int) {
                defer wg.Done()
                eg.Add(ewrap.New(fmt.Sprintf("concurrent error %d", i)))
            }(i)
        }

        wg.Wait()

        // Verify all errors were captured
        errs := eg.Errors()
        if len(errs) != 100 {
            t.Errorf("Expected 100 errors, got %d", len(errs))
        }
    })
}

Testing Circuit Breakers

Circuit breakers require testing state transitions and timing behavior:

func TestCircuitBreaker(t *testing.T) {
    t.Run("State Transitions", func(t *testing.T) {
        cb := ewrap.NewCircuitBreaker("test", 3, time.Second)

        // Should start closed
        if !cb.CanExecute() {
            t.Error("Circuit breaker should start in closed state")
        }

        // Record failures until open
        for i := 0; i < 3; i++ {
            cb.RecordFailure()
        }

        // Should now be open
        if cb.CanExecute() {
            t.Error("Circuit breaker should be open after failures")
        }

        // Wait for timeout
        time.Sleep(time.Second + 100*time.Millisecond)

        // Should be half-open
        if !cb.CanExecute() {
            t.Error("Circuit breaker should be half-open after timeout")
        }

        // Record success to close
        cb.RecordSuccess()

        // Should be closed
        if !cb.CanExecute() {
            t.Error("Circuit breaker should be closed after success")
        }
    })
}

Performance Testing

Performance testing is crucial for error handling code:

func BenchmarkErrorOperations(b *testing.B) {
    // Benchmark error creation
    b.Run("Creation", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            _ = ewrap.New("test error")
        }
    })

    // Benchmark error wrapping
    b.Run("Wrapping", func(b *testing.B) {
        baseErr := errors.New("base error")
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            _ = ewrap.Wrap(baseErr, "wrapped error")
        }
    })

    // Benchmark error group operations
    b.Run("ErrorGroup", func(b *testing.B) {
        pool := ewrap.NewErrorGroupPool(4)
        b.ReportAllocs()
        b.RunParallel(func(pb *testing.PB) {
            for pb.Next() {
                eg := pool.Get()
                eg.Add(errors.New("test error"))
                _ = eg.Error()
                eg.Release()
            }
        })
    })
}

Integration Testing

Testing error handling in integration scenarios:

func TestErrorIntegration(t *testing.T) {
    // Create a test server
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Simulate error handling in an HTTP server
        err := processRequest(r)
        if err != nil {
            // Convert error to API response
            resp := formatErrorResponse(err)
            w.WriteHeader(http.StatusInternalServerError)
            json.NewEncoder(w).Encode(resp)
            return
        }
        w.WriteHeader(http.StatusOK)
    }))
    defer srv.Close()

    // Test error handling through the entire stack
    t.Run("Integration", func(t *testing.T) {
        resp, err := http.Get(srv.URL)
        if err != nil {
            t.Fatal(err)
        }
        defer resp.Body.Close()

        // Verify error response format
        if resp.StatusCode != http.StatusInternalServerError {
            t.Errorf("Expected status 500, got %d", resp.StatusCode)
        }

        var errorResp map[string]interface{}
        if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
            t.Fatal(err)
        }

        // Verify error response structure
        requiredFields := []string{"message", "code", "timestamp"}
        for _, field := range requiredFields {
            if _, ok := errorResp[field]; !ok {
                t.Errorf("Missing required field: %s", field)
            }
        }
    })
}