Testing Gin applications

18 minutes read

You have already learned the fundamentals of Gin and how it stands out as a lightweight yet robust web framework for building web applications in Go.

As a developer, ensuring the reliability of your web application is crucial. In this topic, you'll learn step-by-step how to test a Gin application. You'll first build a simple Gin app and then walk through the process of writing and running test cases.

Building a simple Gin app

Let's start by creating a simple Gin app to manage an in-memory movie catalog. This application, named movies-app will feature two endpoints: one for retrieving movie details by ID and another for adding a new movie to the catalog.

The first step is to create a new Go project named movies-app, initialize Go modules, and install the Gin package via the following commands:

mkdir movies-app && cd movies-app
go mod init movies-app
go get github.com/gin-gonic/gin

Now, let's create a new file main.go, and within it, let's write the code for the movie catalog application:

🔍 Click to view the full code of main.go
// main.go

package main

import (
    "fmt"
    "log"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
)

// Movie represents the data structure for a movie in the catalog
type Movie struct {
    ID       int     `json:"id"`
    Title    string  `json:"title"`
    Director string  `json:"director"`
    Studio   string  `json:"studio"`
    Rating   float64 `json:"rating"`
}

// movies holds the in-memory movie catalog
var movies = []Movie{
    {ID: 1, Title: "Inception", Director: "Christopher Nolan", Studio: "Warner Bros.", Rating: 8.8},
}

// GetMovieByID is a helper that retrieves a movie by its ID from the catalog.
func GetMovieByID(id int) (*Movie, bool) {
    for _, movie := range movies {
        if movie.ID == id {
            return &movie, true
        }
    }
    return nil, false
}

// GetMovie handles GET requests to retrieve a movie by ID.
func GetMovie(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        msg := fmt.Sprintf("Invalid ID format: %s", c.Param("id"))
        c.JSON(http.StatusBadRequest, gin.H{"error": msg})
        return
    }

    movie, found := GetMovieByID(id)
    if !found {
        msg := fmt.Sprintf("Movie with ID %d not found", id)
        c.JSON(http.StatusNotFound, gin.H{"error": msg})
        return
    }

    c.JSON(http.StatusOK, movie)
}

// CreateMovie handles POST requests to add a new movie to the catalog.
func CreateMovie(c *gin.Context) {
    var newMovie Movie
    if err := c.ShouldBindJSON(&newMovie); err != nil {
        msg := fmt.Sprintf("Invalid movie data: %v", err)
        c.JSON(http.StatusBadRequest, gin.H{"error": msg})
        return
    }

    // Prevent adding a movie with an existing ID:
    if _, found := GetMovieByID(newMovie.ID); found {
        msg := fmt.Sprintf("Movie with ID %d already exists", newMovie.ID)
        c.JSON(http.StatusBadRequest, gin.H{"error": msg})
        return
    }

    movies = append(movies, newMovie)
    c.JSON(http.StatusCreated, newMovie)
}

func main() {
    router := gin.Default()

    router.GET("/movie/:id", GetMovie)
    router.POST("/movie", CreateMovie)

    if err := router.Run(); err != nil {
        log.Fatalf("failed to run the server: %v", err)
    }
}

In the above code, the Gin application has two crucial functions to manage the in-memory movie catalog:

  • GetMovie(): Tied to the GET /movie/:id endpoint; this function retrieves a movie by its ID from the movies slice. It ensures users can query movie details effectively.

  • CreateMovie(): Associated with the POST /movie endpoint, it allows adding a new movie to the catalog; this function handles data validation and updates the movies slice.

Setting up the testing environment

After setting up the project workspace and writing the code for the movie catalog app, the next step is to create a file named main_test.go, and within it, write the following boilerplate code:

// main_test.go

package main

import (
    "github.com/gin-gonic/gin"
    "testing"
)

func init() {
    // Set Gin to Test Mode
    gin.SetMode(gin.TestMode)
}

func TestGetMovie(t *testing.T) {
    // Placeholder for GetMovie endpoint test
}

func TestCreateMovie(t *testing.T) {
    // Placeholder for CreateMovie endpoint test
}

In the init() function, the line gin.SetMode(gin.TestMode) ensures the Gin application runs under conditions optimized for testing. The TestMode significantly reduces overhead by disabling or simplifying operations irrelevant to testing, such as logging and middleware execution.

Apart from init(), the actual testing functions are TestGetMovie() which will simulate client requests to fetch movie details by ID, and TestCreateMovie() to test the app's functionality to add new movies to the catalog.

Table-driven tests

Table-driven tests are a typical pattern in Go for testing multiple input and output cases using a single test function. Instead of writing separate test functions for each case, you can define a table (a slice of structs) that includes the input values, expected output, and an optional description for each test case. You can then loop through the table and execute the test function for each case.

Compared to individual unit tests, table-driven tests offer several advantages:

  • They reduce code duplication and make your test suite more maintainable.

  • They make it easy to add new test cases, as you simply need to extend the table.

  • They provide a clear overview of the various input-output combinations being tested.

In the following sections, you'll implement table-driven tests for the TestGetMovie() and TestCreateMovie() testing functions.

Writing table-driven tests for TestGetMovie

Having learned about table-driven tests, let's create a list of test cases for TestGetMovie() that verify the application's behavior across various scenarios:

  1. Valid ID: The endpoint should return the corresponding movie details with a 200 OK status when provided with a valid movie ID.

  2. Non-Existent ID: For IDs that do not correspond to any movie in the catalog, the endpoint should return a 404 Not Found status.

  3. Invalid ID Format: If the ID in the request is not a valid integer, the endpoint should respond with a 400 Bad Request status.

Here's the full code that you would write within the TestGetMovie() function:

// main_test.go

func TestGetMovie(t *testing.T) {
    router := gin.Default()
    router.GET("/movie/:id", GetMovie)

    testCases := []struct {
        name         string
        movieID      string
        wantStatus   int
        wantMovie    *Movie
        wantErrorMsg string
    }{
        {
            name:       "Valid ID",
            movieID:    "1",
            wantStatus: http.StatusOK,
            wantMovie:  &Movie{ID: 1, Title: "Inception", Director: "Christopher Nolan", Studio: "Warner Bros.", Rating: 8.8},
        },
        {
            name:         "Non-Existent ID",
            movieID:      "999",
            wantStatus:   http.StatusNotFound,
            wantErrorMsg: `Movie with ID 999 not found`,
        },
        {
            name:         "Invalid ID Format",
            movieID:      "abc",
            wantStatus:   http.StatusBadRequest,
            wantErrorMsg: `Invalid ID format: abc`,
        },
    }

    for _, testCase := range testCases {
        t.Run(testCase.name, func(t *testing.T) {
            url := fmt.Sprintf("/movie/%s", testCase.movieID)
            request, err := http.NewRequest("GET", url, nil)
            if err != nil {
                t.Fatalf("failed to create request: %v", err)
            }
            recorder := httptest.NewRecorder()
            router.ServeHTTP(recorder, request)

            // Assert status code
            if recorder.Code != testCase.wantStatus {
                t.Errorf("%s: expected status %d; got %d",
                    testCase.name, testCase.wantStatus, recorder.Code)
            }

            if testCase.wantMovie != nil {
                // If expecting a movie, unmarshal and compare
                var gotMovie Movie
                if err = json.Unmarshal(recorder.Body.Bytes(), &gotMovie); err != nil {
                    t.Errorf("%s: failed to unmarshal response body: %v",
                        testCase.name, err)
                }
                if gotMovie != *testCase.wantMovie {
                    t.Errorf("%s: expected movie %+v; got %+v",
                        testCase.name, *testCase.wantMovie, gotMovie)
                }
            }

            if testCase.wantErrorMsg != "" {
                // If expecting an error, compare error message
                var gotError map[string]string
                if err = json.Unmarshal(recorder.Body.Bytes(), &gotError); err != nil {
                    t.Errorf("%s: failed to unmarshal error response: %v",
                        testCase.name, err)
                }
                if gotError["error"] != testCase.wantErrorMsg {
                    t.Errorf("%s: expected error msg %s; got %s",
                        testCase.name, testCase.wantErrorMsg, gotError["error"])
                }
            }
        })
    }
}

In the above code, the testCases struct slice defines test cases for the three scenarios. Each test case specifies the input (movieID), the expected output (wantMovie or wantError), and the expected HTTP status code (wantStatus).

Then, the code iterates through each test case using the t.Run() method, which allows for executing sub-tests that can be identified by their name. For each test, an HTTP request is simulated using http.NewRequest, and the response is captured with httptest.NewRecorder, a mock writer that records the HTTP response. Based on the scenario—whether the ID is valid, non-existent, or invalid—the test asserts the HTTP status code and, if applicable, the accuracy of the returned movie data or error message against expected values.

Writing table-driven tests for TestCreateMovie

Let's now implement the test cases for the TestCreateMovie() to validate its functionality under the following conditions:

  1. Add New Movie: This test case verifies that a well-formed request to add a new movie results in a 201 Created status and the movie being correctly added to the catalog.

  2. Duplicate Movie ID: Since each movie in our catalog must have a unique ID, this test ensures that attempting to add a movie with an ID that already exists in the catalog results in a 400 Bad Request status.

  3. Invalid Movie Data: This case tests the scenario where the request body does not meet the expected format, or misses required fields, expecting a 400 Bad Request status in response.

Below is the entire code that you would write within the TestCreateMovie() function:

// main_test.go

func TestCreateMovie(t *testing.T) {
    router := gin.Default()
    router.POST("/movie", CreateMovie)

    testCases := []struct {
        name         string
        requestBody  string
        wantStatus   int
        wantMovie    *Movie
        wantErrorMsg string
    }{
        {
            name:        "Add New Movie",
            requestBody: `{"id":2,"title":"The Dark Knight","director":"Christopher Nolan","studio":"Warner Bros.","rating":9.0}`,
            wantStatus:  http.StatusCreated,
            wantMovie:   &Movie{ID: 2, Title: "The Dark Knight", Director: "Christopher Nolan", Studio: "Warner Bros.", Rating: 9.0},
        },
        {
            name:         "Duplicate Movie ID",
            requestBody:  `{"id":1,"title":"Inception","director":"Christopher Nolan","studio":"Warner Bros.","rating":8.8}`,
            wantStatus:   http.StatusBadRequest,
            wantErrorMsg: "Movie with ID 1 already exists",
        },
        {
            name:         "Invalid Movie Data - Malformed JSON",
            requestBody:  `{"id":3, "title": "New Movie", "director":}`,
            wantStatus:   http.StatusBadRequest,
            wantErrorMsg: "Invalid movie data: invalid character '}' looking for beginning of value",
        },
    }

    for _, testCase := range testCases {
        t.Run(testCase.name, func(t *testing.T) {
            recorder := httptest.NewRecorder()
            request, err := http.NewRequest("POST",
                "/movie", strings.NewReader(testCase.requestBody))
            if err != nil {
                t.Fatalf("failed to create request: %v", err)
            }
            request.Header.Set("Content-Type", "application/json")

            router.ServeHTTP(recorder, request)

            if recorder.Code != testCase.wantStatus {
                t.Errorf("%s: expected status %d; got %d",
                    testCase.name, testCase.wantStatus, recorder.Code)
            }

            if testCase.wantMovie != nil {
                var gotMovie Movie
                if err = json.NewDecoder(recorder.Body).Decode(&gotMovie); err != nil {
                    t.Fatalf("%s: failed to decode response body: %v",
                        testCase.name, err)
                }
                if gotMovie != *testCase.wantMovie {
                    t.Errorf("%s: expected movie %+v; got %+v",
                        testCase.name, *testCase.wantMovie, gotMovie)
                }
            }

            if testCase.wantErrorMsg != "" {
                var gotError map[string]string
                if err = json.NewDecoder(recorder.Body).Decode(&gotError); err != nil {
                    t.Fatalf("%s: failed to decode error response: %v",
                        testCase.name, err)
                }
                if gotError["error"] != testCase.wantErrorMsg {
                    t.Errorf("%s: expected error msg '%s'; got '%s'",
                        testCase.name, testCase.wantErrorMsg, gotError["error"])
                }
            }
        })
    }
}

The code of TestCreateMovie also uses table-driven tests to validate the movie creation process under various conditions. Each test case includes a JSON request body representing the movie to be added.

Running tests

Finally, you can use the go test command to execute all test functions in the package and report the results:

[GIN] 2024/02/10 - 13:23:33 | 200 |     151.625µs |                 | GET      "/movie/1"
[GIN] 2024/02/10 - 13:23:33 | 404 |      14.375µs |                 | GET      "/movie/999"
[GIN] 2024/02/10 - 13:23:33 | 400 |       9.125µs |                 | GET      "/movie/abc"
[GIN] 2024/02/10 - 13:23:33 | 201 |      95.667µs |                 | POST     "/movie"
[GIN] 2024/02/10 - 13:23:33 | 400 |      14.083µs |                 | POST     "/movie"
[GIN] 2024/02/10 - 13:23:33 | 400 |       98.75µs |                 | POST     "/movie"
PASS
ok  	movies-app	0.184s

The above output indicates that all tests have passed successfully and that the execution time for the entire test suite was 0.184 seconds. The output also provides a log of HTTP requests executed during the test cases and their corresponding response codes.

🔍 Click to view a detailed explanation of the test cases log
  • The first test case made a GET request to /movie/1 and received a 200 OK response, indicating the movie with ID 1 was found.

  • The second test case made a GET request /movie/999, which resulted in a 404 Not Found response since there is no movie with ID 999.

  • The third test case made a GET request to /movie/abc, which returned a 400 Bad Request response due to an invalid ID format.

  • The fourth test case sent a POST request to create a new movie, which succeeded with a 201 Created response.

  • The fifth test case attempted to POST a new movie with an existing ID, which was correctly rejected with a 400 Bad Request response.

  • The sixth test case submitted a POST request with malformed JSON, resulting in a 400 Bad Request response due to invalid data.

Conclusion

In this topic, you learned how to test a Gin application, from setting up a basic movie catalog app to implementing table-driven tests for its endpoints. You also learned how to interpret the results of tests executed by the go test command.

This was a lengthy topic, but we're not done yet. Let's work on some theory and coding tasks now!

2 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo