How to Write Effective Test Files in Go for REST APIs🧪

Testing is the backbone of reliable software. In Go, writing test files not only ensures correctness but also improves developer confidence, speeds up refactoring, and documents behavior. Let’s walk through how to structure and implement test files for a typical RESTful API using Gin, SQLC, and DTOs.

Go-Simple

In this article, we plan to write unit tests for the Product CRUD operations to ensure the reliability and stability of the application.

📁 1. Organize Your Test Files

Place your test files in the same package as the code they test, but use the _test.go suffix:

text
internal/  product/    handler.go    handler_test.go  test/    admin_test.go    client_test.go

Use package test for integration-style tests and package product_test for unit tests.

🏗️ 2. Scaffold Your Test Suite

Use testing.T and t.Run() to structure your tests. For integration tests, wrap your logic in a helper like WithTestServer:

go
func TestProductsAdmin(t *testing.T) {
    t.Parallel()
    WithTestServer(t, func() {
        // Setup config and base URL
    })
}

This isolates each test run and ensures parallel execution.

🛠️ 3. Create Test Data via API

Use http.Post to create resources:

go
productRequest := sqlc.CreateProductParams{    Name: "Test Product",    Description:
"This is a test product",    Price: 1000,    IsActive: true,}body, _ :=
json.Marshal(productRequest)resp, _ := http.Post(addr+"/api/v1/admin/products", "application/json",
bytes.NewBuffer(body))

Decode the response into a DTO to reuse in later tests.

🔍 4. Test Retrieval and Listing

Use http.Get to verify endpoints like ListProducts and GetProductByID:

go
resp, _ := http.Get(addr + "/api/v1/products")var products
[]dto.ClientListProductsResponsejson.NewDecoder(resp.Body).Decode(&products)

Assert that the product you created appears in the list.

✏️ 5. Test Updates

Use http.NewRequest with PUT to update resources:

go
update := dto.AdminUpdateProductRequest{
    Name:  "Updated",
    Price: 1500,
}

body, _ := json.Marshal(update)
req, _ := http.NewRequest(
    http.MethodPut,
    addr+"/api/v1/admin/products/1",
    bytes.NewBuffer(body),
)
req.Header.Set("Content-Type", "application/json")

client := &http.Client{}
resp, _ := client.Do(req)

Decode and assert the updated fields.

🗑️ 6. Test Deletion and Post-Delete Behavior

Use DELETE and verify the resource is gone:

go
req, _ := http.NewRequest(http.MethodDelete, addr+"/api/v1/admin/products/1", nil)client :=
&http.Client{}resp, _ := client.Do(req)

Then try a GET and expect a 400 or 500 response.

✅ 7. Assert Everything

Use t.Errorf or t.Fatalf to fail tests with meaningful messages:

go
if resp.StatusCode != http.StatusOK {    t.Errorf("Expected 200 OK, got %d", resp.StatusCode)}

Also assert field values, lengths, and error messages.

🧼 8. Clean Up and Isolate

Ensure each test is independent. Use teardown logic or a test database that resets between runs. Avoid relying on global state.

How to Write Effective Test Files in Go for REST APIs🧪

🧠 Final Thoughts

Writing test files is not just about coverage — it’s about clarity, confidence, and collaboration. A well-written test suite lets you refactor fearlessly and onboard teammates faster.

If you’re building modular systems with Uber FX and SQLC, test files become your safety net. Keep them clean, descriptive, and example-driven.

Test Coverage

How to Write Effective Test Files in Go for REST APIs🧪
How to Write Effective Test Files in Go for REST APIs🧪

Test Files

go
package test
import (
"bytes"
"encoding/json"
"fmt"
"go-simple/internal/config"
"go-simple/internal/db/sqlc"
"go-simple/internal/product/dto"
"io"
"net/http"
"testing"
)
func TestProductsAdmin(t *testing.T) {
    // t.Parallel() WithTestServer(t,
    func() {
        cfg, err := config.NewConfig() if err != nil {
            t.Fatalf("Failed to load config: %v", err)
        }
        addr := fmt.Sprintf("http://%s:%d", cfg.HTTPAddress, cfg.HTTPPort) product :=
        dto.ProductResponse{
        }
        t.Run("Create Product", func(t *testing.T) {
            productRequest := sqlc.CreateProductParams{
                Name: "Test Product",
                Description: "This is a test product",
                Price: 1000,
                IsActive:
                true,
            }
            body, err := json.Marshal(productRequest) if err != nil {
                t.Fatalf("Failed to marshal product: %v", err)
            }
            resp, err := http.Post(addr+"/api/v1/admin/products", "application/json",
            bytes.NewBuffer(body)) if err != nil {
                t.Fatalf("Failed to send request: %v", err)
            }
            if resp.StatusCode != http.StatusCreated {
                t.Errorf("Expected status 201 Created, got %d", resp.StatusCode)
                // Optional: Print response body for debugging
                responseBody, _ := io.ReadAll(resp.Body)
                t.Logf("Response body: %s", string(responseBody))
            }
            defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&product);
            err != nil {
                t.Fatalf("Failed to decode response: %v", err)
            }
        }) t.Run("List Products", func(t *testing.T) {
            resp, err := http.Get(addr + "/api/v1/admin/products") if err != nil {
                t.Fatalf("Failed to send GET request: %v", err)
            }
            defer resp.Body.Close() if resp.StatusCode != http.StatusOK {
                t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)
                responseBody, _ := io.ReadAll(resp.Body)
                t.Logf("Response body: %s", string(responseBody))
                return
            }
            var products dto.ClientListProductsResponse if err :=
            json.NewDecoder(resp.Body).Decode(&products);
            err != nil {
                t.Fatalf("Failed to decode response: %v", err)
            }
            if len(products) == 0 {
                t.Errorf("Expected at least one product, got 0")
            }
            findCreatedProduct := false for index, p := range products {
                if p.ID == product.ID {
                    findCreatedProduct = true t.Logf("Found created product at index %d: %+v",
                    index, p) break
                }
            }
            if !findCreatedProduct {
                t.Errorf("Created product not found in the list")
            }
        }) t.Run("Get Product By ID", func(t *testing.T) {
            url := fmt.Sprintf("%s/api/v1/admin/products/%d", addr, product.ID) resp, err :=
            http.Get(url) if err != nil {
                t.Fatalf("Failed to send GET request: %v", err)
            }
            defer resp.Body.Close() if resp.StatusCode != http.StatusOK {
                t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)
                responseBody, _ := io.ReadAll(resp.Body)
                t.Logf("Response body: %s", string(responseBody))
                return
            }
            var fetchedProduct dto.ProductResponse if err :=
            json.NewDecoder(resp.Body).Decode(&fetchedProduct);
            err != nil {
                t.Fatalf("Failed to decode response: %v", err)
            }
            if fetchedProduct.ID != product.ID {
                t.Errorf("Expected product ID %d, got %d", product.ID, fetchedProduct.ID)
            }
            if fetchedProduct.Name != product.Name {
                t.Errorf("Expected product name %q, got %q", product.Name, fetchedProduct.Name)
            }
        }) t.Run("Update Product", func(t *testing.T) {
            updateRequest := dto.AdminUpdateProductRequest{
                Name: "Updated Product",
                Description: "Updated description",
                Price: 1500,
                IsActive:
                false,
            }
            body, err := json.Marshal(updateRequest) if err != nil {
                t.Fatalf("Failed to marshal update request: %v", err)
            }
            req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/admin/products/%d",
            addr, product.ID), bytes.NewBuffer(body)) if err != nil {
                t.Fatalf("Failed to create PUT request: %v", err)
            }
            req.Header.Set("Content-Type", "application/json") client := &http.Client{
            }
            resp, err := client.Do(req) if err != nil {
                t.Fatalf("Failed to send PUT request: %v", err)
            }
            defer resp.Body.Close() if resp.StatusCode != http.StatusOK {
                t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)
                responseBody, _ := io.ReadAll(resp.Body)
                t.Logf("Response body: %s", string(responseBody))
                return
            }
            var updatedProduct dto.ProductResponse if err :=
            json.NewDecoder(resp.Body).Decode(&updatedProduct);
            err != nil {
                t.Fatalf("Failed to decode update response: %v", err)
            }
            if updatedProduct.Name != updateRequest.Name || updatedProduct.Description !=
            updateRequest.Description ||
            updatedProduct.Price != updateRequest.Price {
                t.Errorf("Product update mismatch. Expected %+v, got %+v", updateRequest,
                updatedProduct)
            }
        }) t.Run("Delete Product", func(t *testing.T) {
            req, err := http.NewRequest(http.MethodDelete,
            fmt.Sprintf("%s/api/v1/admin/products/%d", addr, product.ID), nil) if err != nil {
                t.Fatalf("Failed to create DELETE request: %v", err)
            }
            client := &http.Client{
            }
            resp, err := client.Do(req) if err != nil {
                t.Fatalf("Failed to send DELETE request: %v", err)
            }
            defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent {
                t.Errorf("Expected status 204 No Content, got %d", resp.StatusCode)
                responseBody, _ := io.ReadAll(resp.Body)
                t.Logf("Response body: %s", string(responseBody))
            }
        }) t.Run("Verify Product Deleted", func(t *testing.T) {
            resp, err := http.Get(fmt.Sprintf("%s/api/v1/admin/products/%d", addr, product.ID)) if
            err != nil {
                t.Fatalf("Failed to send GET request: %v", err)
            }
            defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest && resp.StatusCode
            != http.StatusInternalServerError {
                t.Errorf("Expected error status after deletion, got %d", resp.StatusCode)
            }
        })
    })
}
go
package test
import (
"bytes"
"encoding/json"
"fmt"
"go-simple/internal/config"
"go-simple/internal/db/sqlc"
"go-simple/internal/product/dto"
"io"
"net/http"
"testing"
)
func TestProductsClient(t *testing.T) {
    // t.Parallel() WithTestServer(t,
    func() {
        cfg, err := config.NewConfig() if err != nil {
            t.Fatalf("Failed to load config: %v", err)
        }
        addr := fmt.Sprintf("http://%s:%d", cfg.HTTPAddress, cfg.HTTPPort) // First, create a
        product via admin API so it's available to the client
var product dto.ProductResponse  t.Run("Create Product (Admin)", func(t *testing.T) {
productRequest := sqlc.CreateProductParams{    Name:        "Client Visible Product",
Description: "Visible to client",    Price:       2000,    IsActive:    true,   }   body, err :=
json.Marshal(productRequest)   if err != nil {    t.Fatalf("Failed to marshal product: %v", err)   }
resp, err := http.Post(addr+"/api/v1/admin/products", "application/json", bytes.NewBuffer(body))
if err != nil {    t.Fatalf("Failed to send request: %v", err)   }   defer resp.Body.Close()   if
resp.StatusCode != http.StatusCreated {    t.Fatalf("Expected status 201 Created, got %d",
resp.StatusCode)   }   if err := json.NewDecoder(resp.Body).Decode(&product); err != nil {
t.Fatalf("Failed to decode response: %v", err)   }  })  t.Run("List Products (Client)", func(t
*testing.T) {   resp, err := http.Get(addr + "/api/v1/products")   if err != nil {
t.Fatalf("Failed to send GET request: %v", err)   }   defer resp.Body.Close()   if resp.StatusCode
!= http.StatusOK {    t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)    responseBody, _
:= io.ReadAll(resp.Body)    t.Logf("Response body: %s", string(responseBody))    return   }   var
products dto.ClientListProductsResponse   if err := json.NewDecoder(resp.Body).Decode(&products);
err != nil {    t.Fatalf("Failed to decode response: %v", err)   }   found := false   for _, p :=
range products {    if p.ID == product.ID {     found = true
t.Logf("Found product in client list: %+v", p)     break    }   }   if !found {
t.Errorf("Product not found in client list")   }  })  t.Run("Get Product By ID (Client)", func(t
*testing.T) {   resp, err := http.Get(fmt.Sprintf("%s/api/v1/products/%d", addr, product.ID))   if
err != nil {    t.Fatalf("Failed to send GET request: %v", err)   }   defer resp.Body.Close()   if
resp.StatusCode != http.StatusOK {    t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)
responseBody, _ := io.ReadAll(resp.Body)    t.Logf("Response body: %s", string(responseBody))
return   }   var fetchedProduct dto.ProductResponse   if err :=
json.NewDecoder(resp.Body).Decode(&fetchedProduct); err != nil {
t.Fatalf("Failed to decode response: %v", err)   }   if fetchedProduct.ID != product.ID {
t.Errorf("Expected product ID %d, got %d", product.ID, fetchedProduct.ID)   }   if
fetchedProduct.Name != product.Name {    t.Errorf("Expected product name %q, got %q", product.Name,
fetchedProduct.Name)   }  })  t.Run("Delete Product", func(t *testing.T) {   req, err :=
http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/products/%d", addr, product.ID),
nil)   if err != nil {    t.Fatalf("Failed to create DELETE request: %v", err)   }   client :=
&http.Client{}   resp, err := client.Do(req)   if err != nil {
t.Fatalf("Failed to send DELETE request: %v", err)   }   defer resp.Body.Close()   if
resp.StatusCode != http.StatusNoContent {    t.Errorf("Expected status 204 No Content, got %d",
resp.StatusCode)    responseBody, _ := io.ReadAll(resp.Body)    t.Logf("Response body: %s",
string(responseBody))   }  }) })}

A message from our Founder

Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.

Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We don’t receive any funding, we do this to support the community. ❤️

If you want to show some love, please take a moment to follow me on LinkedIn, TikTok, Instagram. You can also subscribe to our weekly newsletter.

And before you go, don’t forget to clap and follow the writer️!