Integration and End-to-End Testing

While unit tests are valuable, they don’t verify how components work together. Integration and end-to-end tests fill this gap.

Integration Testing with Test Containers

Test containers allow you to run real dependencies like databases in isolated environments:

package integration

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	
	"myapp/internal/repository"
	"myapp/internal/models"
)

// PostgresContainer represents a PostgreSQL container for testing
type PostgresContainer struct {
	Container testcontainers.Container
	URI       string
	DB        *sqlx.DB
}

// SetupPostgresContainer creates a new PostgreSQL container for testing
func SetupPostgresContainer(ctx context.Context) (*PostgresContainer, error) {
	// Define container request
	req := testcontainers.ContainerRequest{
		Image:        "postgres:14-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "testuser",
			"POSTGRES_PASSWORD": "testpass",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}

	// Start container
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to start container: %w", err)
	}

	// Get host and port
	host, err := container.Host(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to get container host: %w", err)
	}

	port, err := container.MappedPort(ctx, "5432")
	if err != nil {
		return nil, fmt.Errorf("failed to get container port: %w", err)
	}

	// Construct connection URI
	uri := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
		"testuser", "testpass", host, port.Port(), "testdb")

	// Connect to the database
	db, err := sqlx.Connect("postgres", uri)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to database: %w", err)
	}

	// Set connection pool settings
	db.SetMaxOpenConns(5)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(5 * time.Minute)

	// Create a container wrapper
	pgContainer := &PostgresContainer{
		Container: container,
		URI:       uri,
		DB:        db,
	}

	// Run migrations
	if err := runMigrations(db); err != nil {
		return nil, fmt.Errorf("failed to run migrations: %w", err)
	}

	return pgContainer, nil
}

// runMigrations applies database migrations
func runMigrations(db *sqlx.DB) error {
	// Create products table
	_, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS products (
			id VARCHAR(36) PRIMARY KEY,
			name VARCHAR(255) NOT NULL,
			description TEXT,
			price DECIMAL(10, 2) NOT NULL,
			category_id VARCHAR(36) NOT NULL,
			created_at TIMESTAMP NOT NULL DEFAULT NOW(),
			updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
			deleted_at TIMESTAMP
		)
	`)
	if err != nil {
		return err
	}

	// Create categories table
	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS categories (
			id VARCHAR(36) PRIMARY KEY,
			name VARCHAR(255) NOT NULL,
			created_at TIMESTAMP NOT NULL DEFAULT NOW(),
			updated_at TIMESTAMP NOT NULL DEFAULT NOW()
		)
	`)
	return err
}

// TestProductRepositoryIntegration demonstrates integration testing with a real database
func TestProductRepositoryIntegration(t *testing.T) {
	// Skip in short mode
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}

	// Set up test container
	ctx := context.Background()
	pgContainer, err := SetupPostgresContainer(ctx)
	require.NoError(t, err)
	defer func() {
		// Clean up
		if err := pgContainer.Container.Terminate(ctx); err != nil {
			t.Fatalf("Failed to terminate container: %v", err)
		}
	}()

	// Create repository
	productRepo := repository.NewProductRepository(pgContainer.DB)

	// Test case: create and retrieve a product
	t.Run("create and retrieve product", func(t *testing.T) {
		// Create a category first
		categoryID := uuid.New().String()
		_, err := pgContainer.DB.ExecContext(ctx, 
			"INSERT INTO categories (id, name) VALUES ($1, $2)",
			categoryID, "Test Category")
		require.NoError(t, err)

		// Create a new product
		product := &models.Product{
			ID:          uuid.New().String(),
			Name:        "Test Product",
			Description: "A test product for integration testing",
			Price:       99.99,
			CategoryID:  categoryID,
			CreatedAt:   time.Now(),
			UpdatedAt:   time.Now(),
		}

		// Save the product
		err = productRepo.Create(ctx, product)
		require.NoError(t, err)

		// Retrieve the product
		retrieved, err := productRepo.FindByID(ctx, product.ID)
		require.NoError(t, err)
		require.NotNil(t, retrieved)

		// Verify the retrieved product
		assert.Equal(t, product.ID, retrieved.ID)
		assert.Equal(t, product.Name, retrieved.Name)
		assert.Equal(t, product.Description, retrieved.Description)
		assert.Equal(t, product.Price, retrieved.Price)
		assert.Equal(t, product.CategoryID, retrieved.CategoryID)
	})

	// Test case: update a product
	t.Run("update product", func(t *testing.T) {
		// Create a category first
		categoryID := uuid.New().String()
		_, err := pgContainer.DB.ExecContext(ctx, 
			"INSERT INTO categories (id, name) VALUES ($1, $2)",
			categoryID, "Another Category")
		require.NoError(t, err)

		// Create a new product
		product := &models.Product{
			ID:          uuid.New().String(),
			Name:        "Product to Update",
			Description: "This product will be updated",
			Price:       50.00,
			CategoryID:  categoryID,
			CreatedAt:   time.Now(),
			UpdatedAt:   time.Now(),
		}

		// Save the product
		err = productRepo.Create(ctx, product)
		require.NoError(t, err)

		// Update the product
		product.Name = "Updated Product Name"
		product.Price = 75.00
		product.UpdatedAt = time.Now()

		err = productRepo.Update(ctx, product)
		require.NoError(t, err)

		// Retrieve the updated product
		retrieved, err := productRepo.FindByID(ctx, product.ID)
		require.NoError(t, err)
		require.NotNil(t, retrieved)

		// Verify the updated fields
		assert.Equal(t, "Updated Product Name", retrieved.Name)
		assert.Equal(t, 75.00, retrieved.Price)
	})

	// Test case: delete a product
	t.Run("delete product", func(t *testing.T) {
		// Create a category first
		categoryID := uuid.New().String()
		_, err := pgContainer.DB.ExecContext(ctx, 
			"INSERT INTO categories (id, name) VALUES ($1, $2)",
			categoryID, "Delete Test Category")
		require.NoError(t, err)

		// Create a new product
		product := &models.Product{
			ID:          uuid.New().String(),
			Name:        "Product to Delete",
			Description: "This product will be deleted",
			Price:       25.00,
			CategoryID:  categoryID,
			CreatedAt:   time.Now(),
			UpdatedAt:   time.Now(),
		}

		// Save the product
		err = productRepo.Create(ctx, product)
		require.NoError(t, err)

		// Delete the product
		err = productRepo.Delete(ctx, product.ID)
		require.NoError(t, err)

		// Try to retrieve the deleted product
		retrieved, err := productRepo.FindByID(ctx, product.ID)
		assert.Error(t, err)
		assert.Nil(t, retrieved)
		assert.ErrorIs(t, err, repository.ErrProductNotFound)
	})
}

This integration test demonstrates:

  1. Test containers: Using Docker containers for real database testing
  2. Database setup: Creating schema and initial data
  3. Full CRUD operations: Testing create, read, update, and delete
  4. Proper cleanup: Ensuring containers are terminated after tests
  5. Test isolation: Each test case operates independently

API Testing with HTTP Handlers

Testing HTTP handlers ensures your API behaves correctly:

package api

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	
	"myapp/internal/models"
	"myapp/internal/service"
)

// MockProductService is a mock implementation of the ProductService interface
type MockProductService struct {
	mock.Mock
}

// GetProduct mocks the GetProduct method
func (m *MockProductService) GetProduct(ctx context.Context, id string) (*models.Product, error) {
	args := m.Called(ctx, id)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*models.Product), args.Error(1)
}

// ListProducts mocks the ListProducts method
func (m *MockProductService) ListProducts(ctx context.Context, limit, offset int) ([]*models.Product, error) {
	args := m.Called(ctx, limit, offset)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]*models.Product), args.Error(1)
}

// CreateProduct mocks the CreateProduct method
func (m *MockProductService) CreateProduct(ctx context.Context, product *models.Product) error {
	args := m.Called(ctx, product)
	return args.Error(0)
}

// Setup initializes the test environment
func Setup() *gin.Engine {
	// Set Gin to test mode
	gin.SetMode(gin.TestMode)
	
	// Create a new Gin engine
	r := gin.New()
	r.Use(gin.Recovery())
	
	return r
}

// TestGetProductHandler tests the GetProduct HTTP handler
func TestGetProductHandler(t *testing.T) {
	// Setup
	router := Setup()
	mockService := new(MockProductService)
	
	// Create handler with mock service
	handler := NewProductHandler(mockService)
	
	// Register routes
	router.GET("/products/:id", handler.GetProduct)
	
	// Test cases
	testCases := []struct {
		name           string
		productID      string
		setupMock      func()
		expectedStatus int
		expectedBody   map[string]interface{}
	}{
		{
			name:      "successful product retrieval",
			productID: "prod-123",
			setupMock: func() {
				product := &models.Product{
					ID:          "prod-123",
					Name:        "Test Product",
					Description: "A test product",
					Price:       29.99,
					CategoryID:  "cat-456",
					CreatedAt:   time.Now(),
					UpdatedAt:   time.Now(),
				}
				mockService.On("GetProduct", mock.Anything, "prod-123").Return(product, nil)
			},
			expectedStatus: http.StatusOK,
			expectedBody: map[string]interface{}{
				"id":          "prod-123",
				"name":        "Test Product",
				"description": "A test product",
				"price":       29.99,
				"category_id": "cat-456",
			},
		},
		{
			name:      "product not found",
			productID: "non-existent",
			setupMock: func() {
				mockService.On("GetProduct", mock.Anything, "non-existent").Return(nil, service.ErrProductNotFound)
			},
			expectedStatus: http.StatusNotFound,
			expectedBody: map[string]interface{}{
				"error": "Product not found",
			},
		},
		{
			name:      "internal server error",
			productID: "error-id",
			setupMock: func() {
				mockService.On("GetProduct", mock.Anything, "error-id").Return(nil, errors.New("database connection failed"))
			},
			expectedStatus: http.StatusInternalServerError,
			expectedBody: map[string]interface{}{
				"error": "Internal server error",
			},
		},
	}
	
	// Run test cases
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Setup mock expectations
			tc.setupMock()
			
			// Create request
			req, err := http.NewRequest(http.MethodGet, "/products/"+tc.productID, nil)
			require.NoError(t, err)
			
			// Create response recorder
			w := httptest.NewRecorder()
			
			// Serve the request
			router.ServeHTTP(w, req)
			
			// Check status code
			assert.Equal(t, tc.expectedStatus, w.Code)
			
			// Parse response body
			var response map[string]interface{}
			err = json.Unmarshal(w.Body.Bytes(), &response)
			require.NoError(t, err)
			
			// Check response body
			for key, expectedValue := range tc.expectedBody {
				assert.Equal(t, expectedValue, response[key])
			}
			
			// Verify mock expectations
			mockService.AssertExpectations(t)
		})
	}
}

// TestCreateProductHandler tests the CreateProduct HTTP handler
func TestCreateProductHandler(t *testing.T) {
	// Setup
	router := Setup()
	mockService := new(MockProductService)
	
	// Create handler with mock service
	handler := NewProductHandler(mockService)
	
	// Register routes
	router.POST("/products", handler.CreateProduct)
	
	// Test cases
	testCases := []struct {
		name           string
		requestBody    map[string]interface{}
		setupMock      func(map[string]interface{})
		expectedStatus int
		expectedBody   map[string]interface{}
	}{
		{
			name: "successful product creation",
			requestBody: map[string]interface{}{
				"name":        "New Product",
				"description": "A new product",
				"price":       39.99,
				"category_id": "cat-789",
			},
			setupMock: func(body map[string]interface{}) {
				mockService.On("CreateProduct", mock.Anything, mock.MatchedBy(func(p *models.Product) bool {
					return p.Name == body["name"] &&
						   p.Description == body["description"] &&
						   p.Price == body["price"] &&
						   p.CategoryID == body["category_id"]
				})).Return(nil)
			},
			expectedStatus: http.StatusCreated,
			expectedBody: map[string]interface{}{
				"message": "Product created successfully",
			},
		},
		{
			name: "invalid request body",
			requestBody: map[string]interface{}{
				"name":        "",
				"description": "Missing name",
				"price":       39.99,
				"category_id": "cat-789",
			},
			setupMock: func(body map[string]interface{}) {
				// No mock setup needed as validation should fail before service is called
			},
			expectedStatus: http.StatusBadRequest,
			expectedBody: map[string]interface{}{
				"error": "Invalid request body",
			},
		},
		{
			name: "service error",
			requestBody: map[string]interface{}{
				"name":        "Error Product",
				"description": "A product that causes an error",
				"price":       39.99,
				"category_id": "cat-789",
			},
			setupMock: func(body map[string]interface{}) {
				mockService.On("CreateProduct", mock.Anything, mock.Anything).Return(errors.New("database error"))
			},
			expectedStatus: http.StatusInternalServerError,
			expectedBody: map[string]interface{}{
				"error": "Internal server error",
			},
		},
	}
	
	// Run test cases
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			// Setup mock expectations
			tc.setupMock(tc.requestBody)
			
			// Create request body
			jsonBody, err := json.Marshal(tc.requestBody)
			require.NoError(t, err)
			
			// Create request
			req, err := http.NewRequest(http.MethodPost, "/products", bytes.NewBuffer(jsonBody))
			require.NoError(t, err)
			req.Header.Set("Content-Type", "application/json")
			
			// Create response recorder
			w := httptest.NewRecorder()
			
			// Serve the request
			router.ServeHTTP(w, req)
			
			// Check status code
			assert.Equal(t, tc.expectedStatus, w.Code)
			
			// Parse response body
			var response map[string]interface{}
			err = json.Unmarshal(w.Body.Bytes(), &response)
			require.NoError(t, err)
			
			// Check response body
			for key, expectedValue := range tc.expectedBody {
				assert.Equal(t, expectedValue, response[key])
			}
			
			// Verify mock expectations
			mockService.AssertExpectations(t)
		})
	}
}

This API testing approach demonstrates:

  1. HTTP handler testing: Using httptest to simulate HTTP requests
  2. Response validation: Checking status codes and response bodies
  3. Multiple scenarios: Testing success, validation errors, and server errors
  4. Mock services: Isolating handlers from business logic
  5. Matcher functions: Using mock.MatchedBy for flexible argument matching

End-to-End Testing with API Clients

For true end-to-end testing, we can test the entire API as a client would:

package e2e

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	
	"myapp/internal/api"
	"myapp/internal/config"
	"myapp/internal/db"
	"myapp/internal/models"
	"myapp/internal/repository"
	"myapp/internal/service"
)

// TestEnvironment holds all the components needed for E2E testing
type TestEnvironment struct {
	Router       *gin.Engine
	Server       *httptest.Server
	Config       *config.Config
	DB           *db.Database
	PgContainer  testcontainers.Container
	ProductRepo  *repository.ProductRepository
	ProductSvc   *service.ProductService
	CategoryRepo *repository.CategoryRepository
	CategorySvc  *service.CategoryService
}

// SetupTestEnvironment creates a complete test environment with real dependencies
func SetupTestEnvironment(t *testing.T) *TestEnvironment {
	// Skip in short mode
	if testing.Short() {
		t.Skip("Skipping E2E test in short mode")
	}

	// Create context
	ctx := context.Background()

	// Start PostgreSQL container
	pgContainer, pgURI := startPostgresContainer(t, ctx)

	// Create configuration
	cfg := &config.Config{
		Database: config.DatabaseConfig{
			URI: pgURI,
		},
		Server: config.ServerConfig{
			Port: 8080,
		},
	}

	// Initialize database
	database, err := db.NewDatabase(cfg)
	require.NoError(t, err)

	// Run migrations
	err = database.Migrate()
	require.NoError(t, err)

	// Create repositories
	productRepo := repository.NewProductRepository(database.DB)
	categoryRepo := repository.NewCategoryRepository(database.DB)

	// Create services
	productSvc := service.NewProductService(productRepo, categoryRepo)
	categorySvc := service.NewCategoryService(categoryRepo)

	// Create API handlers
	productHandler := api.NewProductHandler(productSvc)
	categoryHandler := api.NewCategoryHandler(categorySvc)

	// Set up router
	gin.SetMode(gin.TestMode)
	router := gin.New()
	router.Use(gin.Recovery())

	// Register routes
	api.RegisterRoutes(router, productHandler, categoryHandler)

	// Create test server
	server := httptest.NewServer(router)

	return &TestEnvironment{
		Router:       router,
		Server:       server,
		Config:       cfg,
		DB:           database,
		PgContainer:  pgContainer,
		ProductRepo:  productRepo,
		ProductSvc:   productSvc,
		CategoryRepo: categoryRepo,
		CategorySvc:  categorySvc,
	}
}

// CleanupTestEnvironment tears down the test environment
func CleanupTestEnvironment(t *TestEnvironment) {
	// Close test server
	if t.Server != nil {
		t.Server.Close()
	}

	// Close database connection
	if t.DB != nil {
		t.DB.Close()
	}

	// Stop container
	if t.PgContainer != nil {
		ctx := context.Background()
		if err := t.PgContainer.Terminate(ctx); err != nil {
			fmt.Fprintf(os.Stderr, "Failed to terminate container: %v\n", err)
		}
	}
}

// startPostgresContainer starts a PostgreSQL container for testing
func startPostgresContainer(t *testing.T, ctx context.Context) (testcontainers.Container, string) {
	// Define container request
	req := testcontainers.ContainerRequest{
		Image:        "postgres:14-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "testuser",
			"POSTGRES_PASSWORD": "testpass",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}

	// Start container
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	require.NoError(t, err)

	// Get host and port
	host, err := container.Host(ctx)
	require.NoError(t, err)

	port, err := container.MappedPort(ctx, "5432")
	require.NoError(t, err)

	// Construct connection URI
	uri := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
		"testuser", "testpass", host, port.Port(), "testdb")

	return container, uri
}

// TestProductAPI performs end-to-end testing of the product API
func TestProductAPI(t *testing.T) {
	// Set up test environment
	env := SetupTestEnvironment(t)
	defer CleanupTestEnvironment(env)

	// Create a client for making requests
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	// Test case: create a category
	t.Run("create category", func(t *testing.T) {
		// Create request body
		categoryData := map[string]interface{}{
			"name": "Test Category",
		}
		jsonData, err := json.Marshal(categoryData)
		require.NoError(t, err)

		// Create request
		req, err := http.NewRequest(http.MethodPost,
			env.Server.URL+"/api/categories",
			bytes.NewBuffer(jsonData))
		require.NoError(t, err)
		req.Header.Set("Content-Type", "application/json")

		// Send request
		resp, err := client.Do(req)
		require.NoError(t, err)
		defer resp.Body.Close()

		// Check status code
		assert.Equal(t, http.StatusCreated, resp.StatusCode)

		// Parse response
		var response map[string]interface{}
		err = json.NewDecoder(resp.Body).Decode(&response)
		require.NoError(t, err)

		// Verify response
		assert.Contains(t, response, "id")
		assert.Contains(t, response, "message")
		assert.Equal(t, "Category created successfully", response["message"])

		// Store category ID for later use
		categoryID := response["id"].(string)
		assert.NotEmpty(t, categoryID)

		// Test case: create a product in that category
		t.Run("create product", func(t *testing.T) {
			// Create request body
			productData := map[string]interface{}{
				"name":        "Test Product",
				"description": "A test product for E2E testing",
				"price":       99.99,
				"category_id": categoryID,
			}
			jsonData, err := json.Marshal(productData)
			require.NoError(t, err)

			// Create request
			req, err := http.NewRequest(http.MethodPost,
				env.Server.URL+"/api/products",
				bytes.NewBuffer(jsonData))
			require.NoError(t, err)
			req.Header.Set("Content-Type", "application/json")

			// Send request
			resp, err := client.Do(req)
			require.NoError(t, err)
			defer resp.Body.Close()

			// Check status code
			assert.Equal(t, http.StatusCreated, resp.StatusCode)

			// Parse response
			var response map[string]interface{}
			err = json.NewDecoder(resp.Body).Decode(&response)
			require.NoError(t, err)

			// Verify response
			assert.Contains(t, response, "id")
			assert.Contains(t, response, "message")
			assert.Equal(t, "Product created successfully", response["message"])

			// Store product ID for later use
			productID := response["id"].(string)
			assert.NotEmpty(t, productID)

			// Test case: get the created product
			t.Run("get product", func(t *testing.T) {
				// Create request
				req, err := http.NewRequest(http.MethodGet,
					env.Server.URL+"/api/products/"+productID, nil)
				require.NoError(t, err)

				// Send request
				resp, err := client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				// Check status code
				assert.Equal(t, http.StatusOK, resp.StatusCode)

				// Parse response
				var product map[string]interface{}
				err = json.NewDecoder(resp.Body).Decode(&product)
				require.NoError(t, err)

				// Verify product data
				assert.Equal(t, productID, product["id"])
				assert.Equal(t, "Test Product", product["name"])
				assert.Equal(t, "A test product for E2E testing", product["description"])
				assert.Equal(t, 99.99, product["price"])
				assert.Equal(t, categoryID, product["category_id"])
			})

			// Test case: list all products
			t.Run("list products", func(t *testing.T) {
				// Create request
				req, err := http.NewRequest(http.MethodGet,
					env.Server.URL+"/api/products", nil)
				require.NoError(t, err)

				// Send request
				resp, err := client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				// Check status code
				assert.Equal(t, http.StatusOK, resp.StatusCode)

				// Parse response
				var response map[string]interface{}
				err = json.NewDecoder(resp.Body).Decode(&response)
				require.NoError(t, err)

				// Verify response
				assert.Contains(t, response, "products")
				products := response["products"].([]interface{})
				assert.GreaterOrEqual(t, len(products), 1)

				// Find our product in the list
				found := false
				for _, p := range products {
					product := p.(map[string]interface{})
					if product["id"] == productID {
						found = true
						assert.Equal(t, "Test Product", product["name"])
						break
					}
				}
				assert.True(t, found, "Created product not found in product list")
			})

			// Test case: update the product
			t.Run("update product", func(t *testing.T) {
				// Create request body
				updateData := map[string]interface{}{
					"name":        "Updated Product Name",
					"description": "Updated product description",
					"price":       129.99,
					"category_id": categoryID,
				}
				jsonData, err := json.Marshal(updateData)
				require.NoError(t, err)

				// Create request
				req, err := http.NewRequest(http.MethodPut,
					env.Server.URL+"/api/products/"+productID,
					bytes.NewBuffer(jsonData))
				require.NoError(t, err)
				req.Header.Set("Content-Type", "application/json")

				// Send request
				resp, err := client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				// Check status code
				assert.Equal(t, http.StatusOK, resp.StatusCode)

				// Verify the update with a GET request
				req, err = http.NewRequest(http.MethodGet,
					env.Server.URL+"/api/products/"+productID, nil)
				require.NoError(t, err)

				resp, err = client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				assert.Equal(t, http.StatusOK, resp.StatusCode)

				var product map[string]interface{}
				err = json.NewDecoder(resp.Body).Decode(&product)
				require.NoError(t, err)

				assert.Equal(t, "Updated Product Name", product["name"])
				assert.Equal(t, "Updated product description", product["description"])
				assert.Equal(t, 129.99, product["price"])
			})

			// Test case: delete the product
			t.Run("delete product", func(t *testing.T) {
				// Create request
				req, err := http.NewRequest(http.MethodDelete,
					env.Server.URL+"/api/products/"+productID, nil)
				require.NoError(t, err)

				// Send request
				resp, err := client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				// Check status code
				assert.Equal(t, http.StatusOK, resp.StatusCode)

				// Verify deletion with a GET request
				req, err = http.NewRequest(http.MethodGet,
					env.Server.URL+"/api/products/"+productID, nil)
				require.NoError(t, err)

				resp, err = client.Do(req)
				require.NoError(t, err)
				defer resp.Body.Close()

				assert.Equal(t, http.StatusNotFound, resp.StatusCode)
			})
		})
	})
}

This end-to-end test demonstrates:

  1. Complete environment setup: Creating a full test environment with real dependencies
  2. HTTP client testing: Testing the API as a client would
  3. Test flow: Creating, reading, updating, and deleting resources
  4. Nested tests: Building a logical test flow with dependencies
  5. Proper cleanup: Ensuring all resources are released after testing