Skip to main content
Version: v2.2.82 (latest)
Recipes · 17 / 17

Testing Effects

Test IO operations and side effects with fp-go using mocking, dependency injection, and the Reader pattern for deterministic, reliable tests.

01

Testing IOEither

Test IO operations by executing them and asserting on the result.

ioeither_test.go
package main

import (
  "testing"
  IOE "github.com/IBM/fp-go/v2/ioeither"
  F "github.com/IBM/fp-go/v2/function"
)

func readConfig() IOE.IOEither[error, string] {
  return IOE.TryCatch(func() (string, error) {
      // Simulated file read
      return "config-value", nil
  })
}

func TestReadConfig(t *testing.T) {
  // Execute the IO operation
  result := readConfig()()
  
  if result.IsLeft() {
      t.Errorf("Expected success, got error: %v", result.Left())
  }
  
  if result.Right() != "config-value" {
      t.Errorf("Expected 'config-value', got %q", result.Right())
  }
}

func fetchData() IOE.IOEither[error, string] {
  return IOE.Right[error]("data")
}

func processData(data string) IOE.IOEither[error, string] {
  return IOE.Right[error](fmt.Sprintf("processed: %s", data))
}

func pipeline() IOE.IOEither[error, string] {
  return F.Pipe1(
      fetchData(),
      IOE.Chain(processData),
  )
}

func TestPipeline(t *testing.T) {
  result := pipeline()()
  
  if result.IsLeft() {
      t.Fatalf("Expected success, got error: %v", result.Left())
  }
  
  expected := "processed: data"
  if result.Right() != expected {
      t.Errorf("Expected %q, got %q", expected, result.Right())
  }
}

Lazy Evaluation: IOEither operations don't execute until you call them with (). This lets you build and compose operations without side effects.

02

Mocking Dependencies

Use interfaces to enable mocking of external dependencies.

mocking_test.go
package main

import (
  "context"
  "fmt"
  "testing"
  IOE "github.com/IBM/fp-go/v2/ioeither"
)

// Interface for database operations
type Database interface {
  Query(ctx context.Context, sql string) IOE.IOEither[error, []string]
}

// Real implementation
type PostgresDB struct {
  connString string
}

func (db *PostgresDB) Query(ctx context.Context, sql string) IOE.IOEither[error, []string] {
  return IOE.TryCatch(func() ([]string, error) {
      // Real database query
      return []string{"result1", "result2"}, nil
  })
}

// Mock implementation for testing
type MockDB struct {
  queryFunc func(ctx context.Context, sql string) IOE.IOEither[error, []string]
  calls     []string
}

func (m *MockDB) Query(ctx context.Context, sql string) IOE.IOEither[error, []string] {
  m.calls = append(m.calls, sql)
  if m.queryFunc != nil {
      return m.queryFunc(ctx, sql)
  }
  return IOE.Right[error]([]string{})
}

// Function under test
func getUsers(db Database) IOE.IOEither[error, []string] {
  return db.Query(context.Background(), "SELECT * FROM users")
}

func TestGetUsers(t *testing.T) {
  t.Run("successful query", func(t *testing.T) {
      mockDB := &MockDB{
          queryFunc: func(ctx context.Context, sql string) IOE.IOEither[error, []string] {
              return IOE.Right[error]([]string{"user1", "user2"})
          },
      }
      
      result := getUsers(mockDB)()
      
      if result.IsLeft() {
          t.Fatalf("Expected success, got error: %v", result.Left())
      }
      
      users := result.Right()
      if len(users) != 2 {
          t.Errorf("Expected 2 users, got %d", len(users))
      }
      
      // Verify the query was called
      if len(mockDB.calls) != 1 {
          t.Errorf("Expected 1 query call, got %d", len(mockDB.calls))
      }
  })
  
  t.Run("query error", func(t *testing.T) {
      mockDB := &MockDB{
          queryFunc: func(ctx context.Context, sql string) IOE.IOEither[error, []string] {
              return IOE.Left[[]string](fmt.Errorf("connection failed"))
          },
      }
      
      result := getUsers(mockDB)()
      
      if result.IsRight() {
          t.Error("Expected error, got success")
      }
  })
}
03

Reader Pattern for Testing

Use Reader pattern to inject dependencies for testable code.

reader_test.go
package main

import (
  "context"
  "testing"
  RIE "github.com/IBM/fp-go/v2/readerioeither"
  IOE "github.com/IBM/fp-go/v2/ioeither"
)

type Dependencies struct {
  DB     Database
  Logger Logger
}

type Logger interface {
  Info(msg string)
  Error(msg string)
}

type AppEffect[A any] = RIE.ReaderIOEither[Dependencies, error, A]

func getUsersWithLogging() AppEffect[[]string] {
  return RIE.Asks(func(deps Dependencies) IOE.IOEither[error, []string] {
      deps.Logger.Info("Fetching users")
      return deps.DB.Query(context.Background(), "SELECT * FROM users")
  })
}

// Mock logger for testing
type MockLogger struct {
  infos  []string
  errors []string
}

func (m *MockLogger) Info(msg string) {
  m.infos = append(m.infos, msg)
}

func (m *MockLogger) Error(msg string) {
  m.errors = append(m.errors, msg)
}

func TestGetUsersWithLogging(t *testing.T) {
  mockDB := &MockDB{
      queryFunc: func(ctx context.Context, sql string) IOE.IOEither[error, []string] {
          return IOE.Right[error]([]string{"user1", "user2"})
      },
  }
  
  mockLogger := &MockLogger{}
  
  deps := Dependencies{
      DB:     mockDB,
      Logger: mockLogger,
  }
  
  result := getUsersWithLogging()(deps)()
  
  if result.IsLeft() {
      t.Fatalf("Expected success, got error: %v", result.Left())
  }
  
  // Verify logging
  if len(mockLogger.infos) != 1 {
      t.Errorf("Expected 1 info log, got %d", len(mockLogger.infos))
  }
  
  if mockLogger.infos[0] != "Fetching users" {
      t.Errorf("Expected 'Fetching users', got %q", mockLogger.infos[0])
  }
}
04

Testing File & HTTP Operations

Mock file system and HTTP clients for isolated tests.

filesystem_test.go
package main

import (
  "fmt"
  "testing"
  IOE "github.com/IBM/fp-go/v2/ioeither"
  F "github.com/IBM/fp-go/v2/function"
)

type FileSystem interface {
  ReadFile(path string) IOE.IOEither[error, []byte]
  WriteFile(path string, data []byte) IOE.IOEither[error, int]
}

type MockFS struct {
  files map[string][]byte
}

func NewMockFS() *MockFS {
  return &MockFS{
      files: make(map[string][]byte),
  }
}

func (m *MockFS) ReadFile(path string) IOE.IOEither[error, []byte] {
  return IOE.TryCatch(func() ([]byte, error) {
      if data, ok := m.files[path]; ok {
          return data, nil
      }
      return nil, fmt.Errorf("file not found: %s", path)
  })
}

func (m *MockFS) WriteFile(path string, data []byte) IOE.IOEither[error, int] {
  return IOE.TryCatch(func() (int, error) {
      m.files[path] = data
      return len(data), nil
  })
}

func copyFile(fs FileSystem, src, dst string) IOE.IOEither[error, int] {
  return F.Pipe2(
      fs.ReadFile(src),
      IOE.Chain(func(data []byte) IOE.IOEither[error, int] {
          return fs.WriteFile(dst, data)
      }),
  )
}

func TestCopyFile(t *testing.T) {
  fs := NewMockFS()
  fs.files["source.txt"] = []byte("test content")
  
  result := copyFile(fs, "source.txt", "dest.txt")()
  
  if result.IsLeft() {
      t.Fatalf("Expected success, got error: %v", result.Left())
  }
  
  // Verify file was copied
  if data, ok := fs.files["dest.txt"]; !ok {
      t.Error("Destination file not created")
  } else if string(data) != "test content" {
      t.Errorf("Expected 'test content', got %q", string(data))
  }
}
http_test.go
package main

import (
  "encoding/json"
  "fmt"
  "testing"
  IOE "github.com/IBM/fp-go/v2/ioeither"
  F "github.com/IBM/fp-go/v2/function"
)

type HTTPClient interface {
  Get(url string) IOE.IOEither[error, []byte]
  Post(url string, data []byte) IOE.IOEither[error, []byte]
}

type MockHTTPClient struct {
  getFunc  func(url string) IOE.IOEither[error, []byte]
  postFunc func(url string, data []byte) IOE.IOEither[error, []byte]
  calls    []string
}

func (m *MockHTTPClient) Get(url string) IOE.IOEither[error, []byte] {
  m.calls = append(m.calls, "GET "+url)
  if m.getFunc != nil {
      return m.getFunc(url)
  }
  return IOE.Right[error]([]byte("{}"))
}

func (m *MockHTTPClient) Post(url string, data []byte) IOE.IOEither[error, []byte] {
  m.calls = append(m.calls, "POST "+url)
  if m.postFunc != nil {
      return m.postFunc(url, data)
  }
  return IOE.Right[error]([]byte("{}"))
}

type User struct {
  ID   int    `json:"id"`
  Name string `json:"name"`
}

func fetchUser(client HTTPClient, id int) IOE.IOEither[error, User] {
  return F.Pipe2(
      client.Get(fmt.Sprintf("https://api.example.com/users/%d", id)),
      IOE.Chain(func(data []byte) IOE.IOEither[error, User] {
          return IOE.TryCatch(func() (User, error) {
              var user User
              err := json.Unmarshal(data, &user)
              return user, err
          })
      }),
  )
}

func TestFetchUser(t *testing.T) {
  mockClient := &MockHTTPClient{
      getFunc: func(url string) IOE.IOEither[error, []byte] {
          user := User{ID: 1, Name: "Alice"}
          data, _ := json.Marshal(user)
          return IOE.Right[error](data)
      },
  }
  
  result := fetchUser(mockClient, 1)()
  
  if result.IsLeft() {
      t.Fatalf("Expected success, got error: %v", result.Left())
  }
  
  user := result.Right()
  if user.Name != "Alice" {
      t.Errorf("Expected 'Alice', got %q", user.Name)
  }
  
  // Verify HTTP call was made
  if len(mockClient.calls) != 1 {
      t.Errorf("Expected 1 HTTP call, got %d", len(mockClient.calls))
  }
}
05

Testing Async Operations

Test parallel execution and verify timing characteristics.

async_test.go
package main

import (
  "testing"
  "time"
  A "github.com/IBM/fp-go/v2/array"
  IOE "github.com/IBM/fp-go/v2/ioeither"
)

func slowOperation(n int) IOE.IOEither[error, int] {
  return IOE.TryCatch(func() (int, error) {
      time.Sleep(10 * time.Millisecond)
      return n * 2, nil
  })
}

func TestParallelExecution(t *testing.T) {
  numbers := []int{1, 2, 3, 4, 5}
  
  start := time.Now()
  result := A.Traverse[int](IOE.ApplicativePar[error, int]())(
      slowOperation,
  )(numbers)()
  duration := time.Since(start)
  
  if result.IsLeft() {
      t.Fatalf("Expected success, got error: %v", result.Left())
  }
  
  // Parallel execution should be faster than sequential
  // 5 operations * 10ms = 50ms sequential
  // Should complete in ~10-20ms parallel
  if duration > 30*time.Millisecond {
      t.Errorf("Parallel execution too slow: %v", duration)
  }
  
  expected := []int{2, 4, 6, 8, 10}
  if !equalSlices(result.Right(), expected) {
      t.Errorf("Expected %v, got %v", expected, result.Right())
  }
}

func equalSlices[A comparable](a, b []A) bool {
  if len(a) != len(b) {
      return false
  }
  for i := range a {
      if a[i] != b[i] {
          return false
      }
  }
  return true
}
06

Testing Error & Retry Logic

Test error handling and retry mechanisms with controlled failures.

retry_test.go
package main

import (
  "fmt"
  "testing"
  IOE "github.com/IBM/fp-go/v2/ioeither"
)

func withRetry[A any](maxAttempts int, operation IOE.IOEither[error, A]) IOE.IOEither[error, A] {
  return func() IOE.Either[error, A] {
      var lastErr error
      for i := 0; i < maxAttempts; i++ {
          result := operation()
          if result.IsRight() {
              return result
          }
          lastErr = result.Left()
      }
      return IOE.Left[A](fmt.Errorf("failed after %d attempts: %w", maxAttempts, lastErr))()
  }
}

func TestRetry(t *testing.T) {
  t.Run("succeeds on first attempt", func(t *testing.T) {
      attempts := 0
      operation := IOE.TryCatch(func() (string, error) {
          attempts++
          return "success", nil
      })
      
      result := withRetry(3, operation)()
      
      if result.IsLeft() {
          t.Errorf("Expected success, got error: %v", result.Left())
      }
      
      if attempts != 1 {
          t.Errorf("Expected 1 attempt, got %d", attempts)
      }
  })
  
  t.Run("succeeds on third attempt", func(t *testing.T) {
      attempts := 0
      operation := IOE.TryCatch(func() (string, error) {
          attempts++
          if attempts < 3 {
              return "", fmt.Errorf("attempt %d failed", attempts)
          }
          return "success", nil
      })
      
      result := withRetry(3, operation)()
      
      if result.IsLeft() {
          t.Errorf("Expected success, got error: %v", result.Left())
      }
      
      if attempts != 3 {
          t.Errorf("Expected 3 attempts, got %d", attempts)
      }
  })
  
  t.Run("fails after max attempts", func(t *testing.T) {
      attempts := 0
      operation := IOE.TryCatch(func() (string, error) {
          attempts++
          return "", fmt.Errorf("attempt %d failed", attempts)
      })
      
      result := withRetry(3, operation)()
      
      if result.IsRight() {
          t.Error("Expected error, got success")
      }
      
      if attempts != 3 {
          t.Errorf("Expected 3 attempts, got %d", attempts)
      }
  })
}