Unit Testing a Modular Go Fiber Application with Firestore

Introduction

In our previous post, we structured a modular Go Fiber application with Firestore. Now, let’s explore how to write effective unit tests for this application. We’ll cover testing strategies for each component and show how to leverage our modular structure for easier and more maintainable tests.

Why Unit Testing Matters

Unit testing is crucial for several reasons:

  1. It helps catch bugs early in the development process
  2. It serves as documentation for how components should behave
  3. It makes refactoring easier and safer
  4. It improves overall code quality and design

Test Directory Structure

Let’s start by defining a test directory structure that mirrors our application structure:

project_root/
│
├── internal/
│   ├── config/
│   │   └── config_test.go
│   ├── handlers/
│   │   └── user_handler_test.go
│   ├── models/
│   │   └── user_test.go
│   ├── repository/
│   │   └── firestore/
│   │       └── user_repository_test.go
│   └── routes/
│       └── routes_test.go
│
└── pkg/
    └── database/
        └── firestore_test.go

Writing Unit Tests

Let’s go through each component and write appropriate unit tests.

Testing Models (internal/models/user_test.go)

For models, we typically test any methods or validations:

package models

import (
    "testing"
)

func TestUser_Validate(t *testing.T) {
    tests := []struct {
        name    string
        user    User
        wantErr bool
    }{
        {
            name:    "Valid user",
            user:    User{Name: "John Doe", Email: "john@example.com"},
            wantErr: false,
        },
        {
            name:    "Invalid email",
            user:    User{Name: "John Doe", Email: "invalid-email"},
            wantErr: true,
        },
        // Add more test cases as needed
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if err := tt.user.Validate(); (err != nil) != tt.wantErr {
                t.Errorf("User.Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Testing Repositories (internal/repository/firestore/user_repository_test.go)

For repositories, we often use mocks to avoid actual database calls:

package firestore

import (
    "context"
    "testing"

    "cloud.google.com/go/firestore"
    "github.com/stretchr/testify/mock"
    "your_module/internal/models"
)

type mockFirestoreClient struct {
    mock.Mock
}

func (m *mockFirestoreClient) Collection(path string) *firestore.CollectionRef {
    args := m.Called(path)
    return args.Get(0).(*firestore.CollectionRef)
}

// Implement other necessary methods...

func TestUserRepository_Create(t *testing.T) {
    mockClient := new(mockFirestoreClient)
    repo := NewUserRepository(mockClient)

    user := &models.User{Name: "John Doe", Email: "john@example.com"}

    mockClient.On("Collection", "users").Return(&firestore.CollectionRef{})
    // Set up more expectations as needed

    err := repo.Create(user)

    if err != nil {
        t.Errorf("Expected no error, got %v", err)
    }

    mockClient.AssertExpectations(t)
}

// Add more tests for Get, Update, Delete methods...

Testing Handlers (internal/handlers/user_handler_test.go)

For handlers, we test the HTTP layer using Fiber’s test utilities:

package handlers

import (
    "encoding/json"
    "net/http/httptest"
    "testing"

    "github.com/gofiber/fiber/v2"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "your_module/internal/models"
    "your_module/internal/repository/firestore"
)

type mockUserRepository struct {
    mock.Mock
}

func (m *mockUserRepository) Create(user *models.User) error {
    args := m.Called(user)
    return args.Error(0)
}

// Implement other necessary methods...

func TestUserHandler_Create(t *testing.T) {
    mockRepo := new(mockUserRepository)
    handler := NewUserHandler(mockRepo)

    app := fiber.New()
    app.Post("/users", handler.Create)

    user := models.User{Name: "John Doe", Email: "john@example.com"}
    mockRepo.On("Create", mock.AnythingOfType("*models.User")).Return(nil)

    body, _ := json.Marshal(user)
    req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")

    resp, _ := app.Test(req)

    assert.Equal(t, 201, resp.StatusCode)
    mockRepo.AssertExpectations(t)
}

// Add more tests for Get, Update, Delete handlers...

Testing Routes (internal/routes/routes_test.go)

For routes, we ensure that our routes are correctly set up:

package routes

import (
    "testing"

    "github.com/gofiber/fiber/v2"
    "github.com/stretchr/testify/assert"
    "your_module/internal/handlers"
    "your_module/internal/repository/firestore"
)

func TestSetupRoutes(t *testing.T) {
    app := fiber.New()
    mockClient := &mockFirestoreClient{}
    SetupRoutes(app, mockClient)

    routes := app.GetRoutes()

    expectedRoutes := map[string]string{
        "/api/users":     "POST",
        "/api/users/:id": "GET",
        // Add more expected routes...
    }

    for path, method := range expectedRoutes {
        route := findRoute(routes, path, method)
        assert.NotNil(t, route, "Route not found: %s %s", method, path)
    }
}

func findRoute(routes [][]*fiber.Route, path, method string) *fiber.Route {
    for _, routeGroup := range routes {
        for _, route := range routeGroup {
            if route.Path == path && route.Method == method {
                return route
            }
        }
    }
    return nil
}

Testing Database Connection (pkg/database/firestore_test.go)

For database connections, we typically test the connection logic:

package database

import (
    "testing"
    "os"
)

func TestNewFirestoreClient(t *testing.T) {
    // Set up a test credentials file path
    credentialsPath := "path/to/test/credentials.json"

    // Ensure the file exists for the test
    if _, err := os.Stat(credentialsPath); os.IsNotExist(err) {
        t.Skipf("Credentials file not found at %s, skipping test", credentialsPath)
    }

    client, err := NewFirestoreClient(credentialsPath)
    if err != nil {
        t.Fatalf("Failed to create Firestore client: %v", err)
    }
    defer client.Close()

    if client == nil {
        t.Error("Expected non-nil client")
    }
}

Running Tests

To run all tests in your project:

go test ./...

To run tests for a specific package:

go test ./internal/handlers

Best Practices for Unit Testing

  1. Use table-driven tests: This allows you to test multiple scenarios easily.
  2. Mock external dependencies: This includes databases, external APIs, etc.
  3. Test edge cases: Don’t just test the happy path; consider error conditions.
  4. Keep tests independent: Each test should be able to run on its own.
  5. Use meaningful test names: The name should describe what’s being tested.
  6. Use assertions: Libraries like testify can make your tests more readable.

Conclusion

Unit testing a modular Go Fiber application with Firestore involves testing each component independently. By following the structure we’ve set up and using mocks for external dependencies, we can create a comprehensive test suite that ensures our application behaves as expected.

Remember, the goal of unit testing is not just to increase code coverage, but to improve the reliability and maintainability of your code. As you develop new features or refactor existing code, your test suite will give you confidence that you haven’t introduced regressions.