Advanced Unit Testing Patterns

While basic unit testing is familiar to most Go developers, advanced patterns can dramatically improve test quality, maintainability, and coverage.

Table-Driven Tests with Sophisticated Fixtures

Table-driven tests are a Go testing staple, but they can be elevated to handle complex scenarios with sophisticated fixtures:

package payment

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestProcessPayment demonstrates advanced table-driven tests with complex fixtures
func TestProcessPayment(t *testing.T) {
	// Define reusable test fixtures
	type testAccount struct {
		id        string
		balance   float64
		currency  string
		isBlocked bool
	}

	type testPayment struct {
		amount      float64
		currency    string
		destination string
		metadata    map[string]interface{}
	}

	// Define test case structure
	type testCase struct {
		name           string
		account        testAccount
		payment        testPayment
		setupMock      func(*mockPaymentProcessor)
		expectedResult *PaymentResult
		expectedError  error
		timeout        time.Duration
	}

	// Create test cases
	testCases := []testCase{
		{
			name: "successful payment with sufficient funds",
			account: testAccount{
				id:       "acc-123",
				balance:  1000.00,
				currency: "USD",
			},
			payment: testPayment{
				amount:      50.00,
				currency:    "USD",
				destination: "acc-456",
				metadata: map[string]interface{}{
					"purpose": "subscription",
				},
			},
			setupMock: func(m *mockPaymentProcessor) {
				m.On("ValidateAccount", "acc-123").Return(true, nil)
				m.On("ProcessTransaction", 
					mock.MatchedBy(func(tx Transaction) bool {
						return tx.Amount == 50.00 && tx.Currency == "USD"
					}),
				).Return("tx-789", nil)
			},
			expectedResult: &PaymentResult{
				TransactionID: "tx-789",
				Status:        "completed",
				ProcessedAt:   time.Now(),
			},
			timeout: 500 * time.Millisecond,
		},
		{
			name: "insufficient funds",
			account: testAccount{
				id:       "acc-123",
				balance:  30.00,
				currency: "USD",
			},
			payment: testPayment{
				amount:      50.00,
				currency:    "USD",
				destination: "acc-456",
			},
			setupMock: func(m *mockPaymentProcessor) {
				m.On("ValidateAccount", "acc-123").Return(true, nil)
				m.On("ProcessTransaction", mock.Anything).Return("", 
					ErrInsufficientFunds)
			},
			expectedError: ErrInsufficientFunds,
			timeout:       500 * time.Millisecond,
		},
		{
			name: "currency mismatch",
			account: testAccount{
				id:       "acc-123",
				balance:  1000.00,
				currency: "USD",
			},
			payment: testPayment{
				amount:      50.00,
				currency:    "EUR",
				destination: "acc-456",
			},
			setupMock: func(m *mockPaymentProcessor) {
				m.On("ValidateAccount", "acc-123").Return(true, nil)
				m.On("ProcessTransaction", mock.Anything).Return("", 
					ErrCurrencyMismatch)
			},
			expectedError: ErrCurrencyMismatch,
			timeout:       500 * time.Millisecond,
		},
		{
			name: "blocked account",
			account: testAccount{
				id:        "acc-123",
				balance:   1000.00,
				currency:  "USD",
				isBlocked: true,
			},
			payment: testPayment{
				amount:      50.00,
				currency:    "USD",
				destination: "acc-456",
			},
			setupMock: func(m *mockPaymentProcessor) {
				m.On("ValidateAccount", "acc-123").Return(false, 
					ErrAccountBlocked)
			},
			expectedError: ErrAccountBlocked,
			timeout:       500 * time.Millisecond,
		},
		{
			name: "timeout during processing",
			account: testAccount{
				id:       "acc-123",
				balance:  1000.00,
				currency: "USD",
			},
			payment: testPayment{
				amount:      50.00,
				currency:    "USD",
				destination: "acc-456",
			},
			setupMock: func(m *mockPaymentProcessor) {
				m.On("ValidateAccount", "acc-123").Return(true, nil)
				m.On("ProcessTransaction", mock.Anything).Run(func(args mock.Arguments) {
					// Simulate a slow operation that will trigger timeout
					time.Sleep(200 * time.Millisecond)
				}).Return("", nil)
			},
			expectedError: context.DeadlineExceeded,
			timeout:       100 * time.Millisecond, // Set timeout shorter than processing time
		},
	}

	// Execute test cases
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Create mock processor and set up expectations
			mockProcessor := new(mockPaymentProcessor)
			if tc.setupMock != nil {
				tc.setupMock(mockProcessor)
			}

			// Create payment service with mock dependencies
			service := NewPaymentService(mockProcessor)

			// Create context with timeout
			ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
			defer cancel()

			// Execute the function being tested
			result, err := service.ProcessPayment(ctx, tc.account.id, tc.payment.amount, 
				tc.payment.currency, tc.payment.destination, tc.payment.metadata)

			// Verify results
			if tc.expectedError != nil {
				assert.True(t, errors.Is(err, tc.expectedError), 
					"Expected error %v, got %v", tc.expectedError, err)
			} else {
				require.NoError(t, err)
				assert.Equal(t, tc.expectedResult.TransactionID, result.TransactionID)
				assert.Equal(t, tc.expectedResult.Status, result.Status)
				assert.WithinDuration(t, tc.expectedResult.ProcessedAt, result.ProcessedAt, 
					2*time.Second)
			}

			// Verify all mock expectations were met
			mockProcessor.AssertExpectations(t)
		})
	}
}

This advanced table-driven test demonstrates several sophisticated techniques:

  1. Complex test fixtures: Structured test data with nested types
  2. Mock setup functions: Each test case configures its own mocks
  3. Context with timeouts: Testing timeout behavior explicitly
  4. Matcher functions: Using mock.MatchedBy for flexible argument matching
  5. Comprehensive assertions: Checking both success and error cases

Behavioral Testing with BDD-Style Assertions

Behavioral testing focuses on describing the expected behavior of your code in a more natural language format:

package user

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/suite"
)

// UserServiceSuite is a test suite for the UserService
type UserServiceSuite struct {
	suite.Suite
	service     *UserService
	mockRepo    *mockUserRepository
	mockAuth    *mockAuthProvider
	mockNotifier *mockNotificationService
	ctx         context.Context
}

// SetupTest runs before each test
func (s *UserServiceSuite) SetupTest() {
	s.mockRepo = new(mockUserRepository)
	s.mockAuth = new(mockAuthProvider)
	s.mockNotifier = new(mockNotificationService)
	s.service = NewUserService(s.mockRepo, s.mockAuth, s.mockNotifier)
	s.ctx = context.Background()
}

// TearDownTest runs after each test
func (s *UserServiceSuite) TearDownTest() {
	// Verify all expectations were met
	s.mockRepo.AssertExpectations(s.T())
	s.mockAuth.AssertExpectations(s.T())
	s.mockNotifier.AssertExpectations(s.T())
}

// TestUserRegistration tests the user registration flow
func (s *UserServiceSuite) TestUserRegistration() {
	// Given a valid user registration request
	req := &RegistrationRequest{
		Email:     "[email protected]",
		Password:  "secureP@ssw0rd",
		FirstName: "Test",
		LastName:  "User",
	}

	// And the email is not already in use
	s.mockRepo.On("FindByEmail", s.ctx, "[email protected]").Return(nil, ErrUserNotFound)

	// And the password meets security requirements
	s.mockAuth.On("ValidatePassword", "secureP@ssw0rd").Return(true, nil)

	// And the password can be hashed
	s.mockAuth.On("HashPassword", "secureP@ssw0rd").Return("hashed_password_123", nil)

	// And the user can be saved to the repository
	s.mockRepo.On("Create", s.ctx, mock.MatchedBy(func(u *User) bool {
		return u.Email == "[email protected]" && 
			   u.FirstName == "Test" && 
			   u.LastName == "User" && 
			   u.PasswordHash == "hashed_password_123"
	})).Return("user_123", nil)

	// And a welcome notification can be sent
	s.mockNotifier.On("SendWelcomeMessage", s.ctx, "[email protected]", "Test").Return(nil)

	// When registering a new user
	userID, err := s.service.RegisterUser(s.ctx, req)

	// Then the registration should succeed
	s.NoError(err)
	s.Equal("user_123", userID)
}

// TestUserRegistrationWithExistingEmail tests registration with an email that's already in use
func (s *UserServiceSuite) TestUserRegistrationWithExistingEmail() {
	// Given a user registration request with an email that's already in use
	req := &RegistrationRequest{
		Email:     "[email protected]",
		Password:  "secureP@ssw0rd",
		FirstName: "Test",
		LastName:  "User",
	}

	// And the email is already in use
	existingUser := &User{
		ID:        "existing_user_123",
		Email:     "[email protected]",
		FirstName: "Existing",
		LastName:  "User",
		CreatedAt: time.Now().Add(-24 * time.Hour),
	}
	s.mockRepo.On("FindByEmail", s.ctx, "[email protected]").Return(existingUser, nil)

	// When registering a new user
	userID, err := s.service.RegisterUser(s.ctx, req)

	// Then the registration should fail with an appropriate error
	s.Empty(userID)
	s.ErrorIs(err, ErrEmailAlreadyExists)
}

// TestUserRegistrationWithWeakPassword tests registration with a weak password
func (s *UserServiceSuite) TestUserRegistrationWithWeakPassword() {
	// Given a user registration request with a weak password
	req := &RegistrationRequest{
		Email:     "[email protected]",
		Password:  "weak",
		FirstName: "Test",
		LastName:  "User",
	}

	// And the email is not already in use
	s.mockRepo.On("FindByEmail", s.ctx, "[email protected]").Return(nil, ErrUserNotFound)

	// And the password fails security validation
	s.mockAuth.On("ValidatePassword", "weak").Return(false, ErrPasswordTooWeak)

	// When registering a new user
	userID, err := s.service.RegisterUser(s.ctx, req)

	// Then the registration should fail with an appropriate error
	s.Empty(userID)
	s.ErrorIs(err, ErrPasswordTooWeak)
}

// Run the test suite
func TestUserServiceSuite(t *testing.T) {
	suite.Run(t, new(UserServiceSuite))
}

This BDD-style test suite demonstrates:

  1. Test suite structure: Using setup and teardown for consistent test environments
  2. Given-When-Then format: Clear separation of test arrangement, action, and assertion
  3. Descriptive test names: Tests that document behavior
  4. Comprehensive scenarios: Testing both happy paths and error cases
  5. Mock verification: Ensuring all expected interactions occurred

Advanced Mocking Strategies

Effective mocking is crucial for isolating the code under test. Here’s an example of advanced mocking techniques:

package database

import (
	"context"
	"database/sql"
	"errors"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/jmoiron/sqlx"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// ProductRepository handles database operations for products
type ProductRepository struct {
	db *sqlx.DB
}

// Product represents a product in the database
type Product struct {
	ID          string    `db:"id"`
	Name        string    `db:"name"`
	Description string    `db:"description"`
	Price       float64   `db:"price"`
	CategoryID  string    `db:"category_id"`
	CreatedAt   time.Time `db:"created_at"`
	UpdatedAt   time.Time `db:"updated_at"`
}

// FindByID retrieves a product by its ID
func (r *ProductRepository) FindByID(ctx context.Context, id string) (*Product, error) {
	query := `
		SELECT id, name, description, price, category_id, created_at, updated_at
		FROM products
		WHERE id = $1 AND deleted_at IS NULL
	`
	
	var product Product
	err := r.db.GetContext(ctx, &product, query, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrProductNotFound
		}
		return nil, err
	}
	
	return &product, nil
}

// TestProductRepositoryFindByID demonstrates advanced database mocking
func TestProductRepositoryFindByID(t *testing.T) {
	// Create a new mock database connection
	mockDB, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer mockDB.Close()
	
	// Wrap with sqlx
	sqlxDB := sqlx.NewDb(mockDB, "postgres")
	
	// Create repository with mocked DB
	repo := &ProductRepository{db: sqlxDB}
	
	// Test case: successful product retrieval
	t.Run("successful product retrieval", func(t *testing.T) {
		// Arrange
		productID := "prod-123"
		expectedProduct := &Product{
			ID:          productID,
			Name:        "Test Product",
			Description: "A test product",
			Price:       29.99,
			CategoryID:  "cat-456",
			CreatedAt:   time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			UpdatedAt:   time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
		}
		
		// Set up the expected query with precise argument and column matching
		rows := sqlmock.NewRows([]string{
			"id", "name", "description", "price", "category_id", "created_at", "updated_at",
		}).AddRow(
			expectedProduct.ID,
			expectedProduct.Name,
			expectedProduct.Description,
			expectedProduct.Price,
			expectedProduct.CategoryID,
			expectedProduct.CreatedAt,
			expectedProduct.UpdatedAt,
		)
		
		mock.ExpectQuery("^SELECT (.+) FROM products WHERE id = \\$1 AND deleted_at IS NULL$").
			WithArgs(productID).
			WillReturnRows(rows)
		
		// Act
		ctx := context.Background()
		product, err := repo.FindByID(ctx, productID)
		
		// Assert
		require.NoError(t, err)
		assert.Equal(t, expectedProduct.ID, product.ID)
		assert.Equal(t, expectedProduct.Name, product.Name)
		assert.Equal(t, expectedProduct.Price, product.Price)
		assert.Equal(t, expectedProduct.CategoryID, product.CategoryID)
		assert.Equal(t, expectedProduct.CreatedAt, product.CreatedAt)
		
		// Verify all expectations were met
		assert.NoError(t, mock.ExpectationsWereMet())
	})
	
	// Test case: product not found
	t.Run("product not found", func(t *testing.T) {
		// Arrange
		productID := "non-existent"
		
		mock.ExpectQuery("^SELECT (.+) FROM products WHERE id = \\$1 AND deleted_at IS NULL$").
			WithArgs(productID).
			WillReturnError(sql.ErrNoRows)
		
		// Act
		ctx := context.Background()
		product, err := repo.FindByID(ctx, productID)
		
		// Assert
		assert.Nil(t, product)
		assert.ErrorIs(t, err, ErrProductNotFound)
		
		// Verify all expectations were met
		assert.NoError(t, mock.ExpectationsWereMet())
	})
	
	// Test case: database error
	t.Run("database error", func(t *testing.T) {
		// Arrange
		productID := "prod-123"
		dbError := errors.New("connection reset by peer")
		
		mock.ExpectQuery("^SELECT (.+) FROM products WHERE id = \\$1 AND deleted_at IS NULL$").
			WithArgs(productID).
			WillReturnError(dbError)
		
		// Act
		ctx := context.Background()
		product, err := repo.FindByID(ctx, productID)
		
		// Assert
		assert.Nil(t, product)
		assert.ErrorIs(t, err, dbError)
		
		// Verify all expectations were met
		assert.NoError(t, mock.ExpectationsWereMet())
	})
	
	// Test case: context cancellation
	t.Run("context cancellation", func(t *testing.T) {
		// Arrange
		productID := "prod-123"
		
		// Create a cancelled context
		ctx, cancel := context.WithCancel(context.Background())
		cancel() // Cancel immediately
		
		// Set up mock to delay response to ensure context cancellation takes effect
		mock.ExpectQuery("^SELECT (.+) FROM products WHERE id = \\$1 AND deleted_at IS NULL$").
			WithArgs(productID).
			WillDelayFor(10 * time.Millisecond).
			WillReturnError(context.Canceled)
		
		// Act
		product, err := repo.FindByID(ctx, productID)
		
		// Assert
		assert.Nil(t, product)
		assert.ErrorIs(t, err, context.Canceled)
		
		// Verify all expectations were met
		assert.NoError(t, mock.ExpectationsWereMet())
	})
}

This example demonstrates:

  1. SQL mocking: Using go-sqlmock to simulate database interactions
  2. Precise query matching: Using regex to match SQL queries
  3. Multiple test scenarios: Testing success, not found, errors, and cancellation
  4. Argument verification: Ensuring correct parameters are passed
  5. Expectations verification: Confirming all expected database calls were made