Lesson 2
Enhancing API Test Structure with Go's Testing Package
Enhancing API Test Structure with Go's Testing Package

Welcome to the second lesson of the course Automating API Tests with Go. In this lesson, we will build on the concepts introduced in the first lesson by enhancing our API test structure using Go’s testing package.

As API test suites grow, maintaining clarity, consistency, and efficiency becomes critical. Well-structured tests improve maintainability, readability, and scalability, ensuring that tests remain effective over time.

What You’ll Learn in This Lesson

By the end of this lesson, you will be able to:

  • Structure API tests using helper functions for reusable logic.
  • Use subtests to organize related test cases.
  • Leverage table-driven tests to test multiple scenarios efficiently.
Refactoring Tests with Helper Functions

In the previous lesson, we wrote a basic API test that verified the /todos endpoint. However, as we add more tests, duplicating logic—such as making HTTP requests and parsing responses—can make tests harder to maintain. Helper functions allow us to extract reusable logic.

Let's refactor our test by introducing a helper function for making API requests:

Go
1package main_test 2 3import ( 4 "encoding/json" 5 "net/http" 6 "testing" 7) 8 9const baseURL = "http://localhost:8000" 10 11// Helper function to make GET requests and parse the JSON response 12func getJSONResponse(t *testing.T, url string, target interface{}) { 13 t.Helper() 14 response, err := http.Get(url) 15 if err != nil { 16 t.Fatalf("Failed to make GET request: %v", err) 17 } 18 defer response.Body.Close() 19 20 if response.StatusCode != http.StatusOK { 21 t.Fatalf("Expected status code 200, got %d", response.StatusCode) 22 } 23 24 if err := json.NewDecoder(response.Body).Decode(target); err != nil { 25 t.Fatalf("Failed to decode response body: %v", err) 26 } 27} 28 29func TestGetAllTodos(t *testing.T) { 30 // Arrange 31 url := baseURL + "/todos" 32 33 // Act & Assert 34 var todos []map[string]interface{} 35 getJSONResponse(t, url, &todos) 36 37 if len(todos) == 0 { 38 t.Error("Expected non-empty list of todos.") 39 } 40 41 // Basic contract validation 42 if _, ok := todos[0]["id"]; !ok { 43 t.Error("Expected field 'id' in response") 44 } 45 if _, ok := todos[0]["title"]; !ok { 46 t.Error("Expected field 'title' in response") 47 } 48}

The t.Helper() function marks a function as a helper, ensuring that if a test fails inside it, the error message points to the actual test function rather than the helper itself. This improves debugging by making test failures easier to trace. Without t.Helper(), errors may appear to originate from the helper function, making it harder to identify which test actually failed. It should always be used at the beginning of helper functions that perform assertions or can cause test failures.

This refactoring reduces duplication and makes our tests cleaner and more maintainable.

Organizing Tests with Subtests

Go’s testing package provides subtests, which help structure related test cases within a single test function. This is particularly useful when testing different aspects of an API response.

Go
1func TestTodoEndpoints(t *testing.T) { 2 url := baseURL + "/todos" 3 4 t.Run("ValidResponse", func(t *testing.T) { 5 var todos []map[string]interface{} 6 getJSONResponse(t, url, &todos) 7 8 if len(todos) == 0 { 9 t.Error("Expected non-empty list of todos.") 10 } 11 }) 12 13 t.Run("ValidDataStructure", func(t *testing.T) { 14 var todos []map[string]interface{} 15 getJSONResponse(t, url, &todos) 16 17 if _, ok := todos[0]["id"]; !ok { 18 t.Error("Expected field 'id' in response") 19 } 20 if _, ok := todos[0]["title"]; !ok { 21 t.Error("Expected field 'title' in response") 22 } 23 }) 24}

Subtests provide better organization and allow independent assertions within related test cases.

Using Table-Driven Tests for Multiple Scenarios

Table-driven tests allow us to define multiple test cases in a structured and reusable manner. This technique is ideal for verifying different API responses with varying inputs. Instead of writing separate test functions for each scenario, we define a slice of test cases and loop through them dynamically.

Here's how it works:

  1. We define a slice of test cases, each containing a name, input (ID), and expected behavior.
  2. We iterate over the slice, running each test case dynamically inside t.Run().
  3. Each test case is isolated, allowing us to verify different scenarios with minimal repetition.
Go
1func TestGetTodoByID(t *testing.T) { 2 tests := []struct { 3 name string 4 id string 5 expectsErr bool 6 }{ 7 {"ValidID", "1", false}, 8 {"InvalidID", "9999", true}, 9 {"NonNumericID", "abc", true}, 10 } 11 12 for _, tc := range tests { 13 t.Run(tc.name, func(t *testing.T) { 14 url := baseURL + "/todos/" + tc.id 15 response, err := http.Get(url) 16 17 if err != nil { 18 t.Fatalf("Failed to make GET request: %v", err) 19 } 20 defer response.Body.Close() 21 22 if tc.expectsErr { 23 if response.StatusCode == http.StatusOK { 24 t.Errorf("Expected error for ID %s, but got status %d", tc.id, response.StatusCode) 25 } 26 } else { 27 if response.StatusCode != http.StatusOK { 28 t.Errorf("Expected status 200 for ID %s, but got %d", tc.id, response.StatusCode) 29 } 30 } 31 }) 32 } 33}

Using table-driven tests ensures we test multiple cases concisely while keeping test logic consistent and scalable. This pattern makes it easier to add new test scenarios in the future without modifying existing logic.

Summary and Next Steps

In this lesson, we improved our API test structure by:

  • Using helper functions to eliminate redundant code.
  • Organizing related tests with subtests.
  • Writing table-driven tests to handle multiple scenarios effectively.

These techniques help create a scalable, maintainable, and efficient API test suite. In the next lesson, we’ll dive into mocking dependencies to test APIs in isolation. Get ready to level up your API testing skills with Go!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.