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:
- Complex test fixtures: Structured test data with nested types
- Mock setup functions: Each test case configures its own mocks
- Context with timeouts: Testing timeout behavior explicitly
- Matcher functions: Using
mock.MatchedBy
for flexible argument matching - 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:
- Test suite structure: Using setup and teardown for consistent test environments
- Given-When-Then format: Clear separation of test arrangement, action, and assertion
- Descriptive test names: Tests that document behavior
- Comprehensive scenarios: Testing both happy paths and error cases
- 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:
- SQL mocking: Using
go-sqlmock
to simulate database interactions - Precise query matching: Using regex to match SQL queries
- Multiple test scenarios: Testing success, not found, errors, and cancellation
- Argument verification: Ensuring correct parameters are passed
- Expectations verification: Confirming all expected database calls were made