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/ginNow, 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 theGET /movie/:idendpoint; this function retrieves a movie by its ID from themoviesslice. It ensures users can query movie details effectively.CreateMovie(): Associated with thePOST /movieendpoint, it allows adding a new movie to the catalog; this function handles data validation and updates themoviesslice.
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:
Valid ID: The endpoint should return the corresponding movie details with a
200 OKstatus when provided with a valid movie ID.Non-Existent ID: For IDs that do not correspond to any movie in the catalog, the endpoint should return a
404 Not Foundstatus.Invalid ID Format: If the ID in the request is not a valid integer, the endpoint should respond with a
400 Bad Requeststatus.
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:
Add New Movie: This test case verifies that a well-formed request to add a new movie results in a
201 Createdstatus and the movie being correctly added to the catalog.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 Requeststatus.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 Requeststatus 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.184sThe 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
GETrequest to/movie/1and received a200 OKresponse, indicating the movie with ID 1 was found.The second test case made a
GETrequest/movie/999, which resulted in a404 Not Foundresponse since there is no movie with ID 999.The third test case made a
GETrequest to/movie/abc, which returned a400 Bad Requestresponse due to an invalid ID format.The fourth test case sent a
POSTrequest to create a new movie, which succeeded with a201 Createdresponse.The fifth test case attempted to
POSTa new movie with an existing ID, which was correctly rejected with a400 Bad Requestresponse.The sixth test case submitted a
POSTrequest with malformed JSON, resulting in a400 Bad Requestresponse 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!