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:
internal/ product/ handler.go handler_test.go test/ admin_test.go client_test.goUse 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:
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:
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:
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:
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:
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:
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.

🧠 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


Test Files
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)
}
})
})
}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️!
