DEV Community

Orololuwa
Orololuwa

Posted on

Setting up handlers with the Repository Pattern: A Test-driven approach in Go.

Introduction

Writing tests are essential in software development to ensure the reliability, maintainability, and quality of software products. In this article, I will be delving into a good approach to setting up and testing handlers in Go called the Repository pattern. The Repository pattern is a developmental approach that allows you to separate your database logic from your application logic to make it easy to test your code.

Prerequisite

  • Basic understanding of the Go programming language

Setting up a DB repo for the database logic

Before we begin setting up our handlers, we need to set up a basic connection to the database and we'd be doing that by implementing the repository pattern for a user model.
We start by initializing the app as a go module using the command

go mod init github.com/orololuwa/reimagined-robot
Enter fullscreen mode Exit fullscreen mode

Typically, you would use a repository from your GitHub as the module name.

In a models package, we create a simple user model like;

package models

import "time"

type User struct {
    ID int
    FirstName string
    LastName  string
    Email     string
    Password string
    CreatedAt time.Time
    UpdatedAt time.Time
}
Enter fullscreen mode Exit fullscreen mode

In a repository folder and package, we would create two files, repository.go and users.go as thus;

package repository

import "github.com/orololuwa/reimagined-robot/models"

type UserRepo interface {
    CreateAUser(user models.User) (int, error)
    GetAUser(id int) (models.User, error)
}
Enter fullscreen mode Exit fullscreen mode
package repository

import (
    "context"
    "database/sql"
    "time"

    "github.com/orololuwa/reimagined-robot/models"
)

type user struct {
    DB *sql.DB
}

func NewUserRepo(conn *sql.DB) UserRepo {
    return &user{
        DB: conn,
    }
}
func (m *user) CreateAUser(user models.User) (int, error){
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    var newId int

    query := `
            INSERT into users 
                (first_name, last_name, email, password, created_at, updated_at)
            values 
                ($1, $2, $3, $4, $5, $6)
            returning id`

    err := m.DB.QueryRowContext(ctx, query, 
        user.FirstName, 
        user.LastName, 
        user.Email, 
        user.Password,
        time.Now(),
        time.Now(),
    ).Scan(&newId)

    if err != nil {
        return 0, err
    }

    return newId, nil
}


func (m *user) GetAUser(id int) (models.User, error){
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    var user models.User

    query := `
            SELECT (id, first_name, last_name, email, password, created_at, updated_at)
            from users
            WHERE
            id=$1
    `

    err := m.DB.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.FirstName,
        &user.LastName,
        &user.Email,
        &user.Password,
        &user.CreatedAt,
        &user.UpdatedAt,
    )

    if err != nil {
        return user, err
    }

    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

In the above snippets, we create a user struct, a NewUserRepo function to initialize the struct, and receiver functions to the struct to handle the database logic. If you want to learn more about setting up a database repo, you can check this out.

Creating the handlers for the application logic

Now we create a handlers.go package where all the application logic will be done.

1) Paste the package declaration at the top of the file and import the packages that would be used in the handler.

package handlers

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"
    "github.com/orololuwa/reimagined-robot/models"
    "github.com/orololuwa/reimagined-robot/repository"
)
Enter fullscreen mode Exit fullscreen mode

2) We create a struct and an initialization function to hold the db repo and any other configs that we might need in our handlers:

type Repository struct {
    User repository.UserRepo
}

var Repo *Repository

func NewHandler(db *sql.DB) {
    r := &Repository{
        User: repository.NewUserRepo(db),
    }
    Repo = r
}
Enter fullscreen mode Exit fullscreen mode

we call the struct Repository because it's going to hold references to the database repositories and any other repository we might create and make available to it, like an in-memory db repository or even an app config. We can see that the initialization function initializes the User field with the repository.NewUserRepo function from the repository package.

3) Create the CreateAUser handler function to create a user:

func (m *Repository) CreateAUser(w http.ResponseWriter, r *http.Request) {
    type userBody struct {
        FirstName string `json:"firstName"`
        LastName string `json:"lastName"`
        Email string `json:"email"`
        Password string `json:"password"`
    }

    var body userBody

    err := json.NewDecoder(r.Body).Decode(&body)
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusInternalServerError)
        responseMap := map[string]interface{}{"message": "error decoding requset body",}

        jsonData, err := json.Marshal(responseMap)
        if err != nil {
            return
        }

        w.Write(jsonData)
        return
    }

    user := models.User{
        FirstName: body.FirstName,
        LastName: body.LastName,
        Email: body.Email,
        Password: body.Password,
    }

    id, err := m.User.CreateAUser(user)
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadRequest)        
        responseMap := map[string]interface{}{"message": "error creating user",}

        jsonData, err := json.Marshal(responseMap)
        if err != nil {
            return
        }

        w.Write(jsonData)
        return
    }

    response := map[string]interface{}{"message": "user created successfully", "data": id}
    jsonResponse, err := json.Marshal(response)
    if err != nil {
        http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    w.Write(jsonResponse)
}
Enter fullscreen mode Exit fullscreen mode

the handler function above receives the HTTP response and a pointer to the request. First, we decode the body of the request and pass it to the CreateAUser function made available to us from the User field. We write error messages to the client if there are any or we write the data.

4) Create the GetAUser handler to get a user by id:

func (m *Repository) GetAUser(w http.ResponseWriter, r *http.Request){
    exploded := strings.Split(r.RequestURI, "/")
    id, err := strconv.Atoi(exploded[2])
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusInternalServerError)
        responseMap := map[string]interface{}{"message": "error decoding id",}

        jsonData, err := json.Marshal(responseMap)
        if err != nil {
            return
        }

        w.Write(jsonData)
        return
    }

    user, err := m.User.GetAUser(id)
    if err != nil {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusNotFound)
        responseMap := map[string]interface{}{"message": "error getting user",}

        jsonData, err := json.Marshal(responseMap)
        if err != nil {
            return
        }

        w.Write(jsonData)
        return
    }

    response := map[string]interface{}{"message": "user created successfully", "data": user}
    jsonResponse, err := json.Marshal(response)
    if err != nil {
        http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    w.Write(jsonResponse)
}
Enter fullscreen mode Exit fullscreen mode

In the handler function above, we decode the id of the user from the URL param, using the function from the chi router. You can install chi router with go get github.com/go-chi/chi/v5. We pass the id to the GetAUser function made available to us from the User field and write the data to the client.

The main.go file.

1) First we create a main.go file, declare the package main, and import the other packages that would be used in the file.

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/orololuwa/reimagined-robot/handlers"
)

Enter fullscreen mode Exit fullscreen mode

2) We declare a run function where we create a connection to the database, initialize our handlers package with the NewHandler function and declare our routes.To open a connection to the database, I'm using a pgx driver called jackc and you can install it using go get github.com/jackc/pgx/v5
At the top of the file, we import the router, the pgx driver with a blank import, and, the other packages used in the main.go file.

func run()(*sql.DB, *chi.Mux, error){
    dbHost := "localhost"
    dbPort := "5432"
    dbName := "bookings"
    dbUser := "orololuwa"
    dbPassword := ""
    dbSSL := "disable"

    // Connecto to DB
    log.Println("Connecting to dabase")
    dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s", dbHost, dbPort, dbName, dbUser, dbPassword, dbSSL)

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        log.Fatal("Cannot conect to database: Dying!", err)
    }
    if err = db.Ping(); err != nil {
        panic(err)
    }
    log.Println("Connected to database")

    handlers.NewHandler(db)
    router := chi.NewRouter()

    router.Use(middleware.Logger)

    router.Post("/user", handlers.Repo.CreateAUser)
    router.Get("/user/{id}", handlers.Repo.GetAUser)
    return db, router, nil
}
Enter fullscreen mode Exit fullscreen mode

Ideally, you should store your database connection details as environment variables.
3) Lastly, we create the main function, call the run function inside of it, and serve our application.

const portNumber = ":8080"
func main(){
    db, route, err := run()
    if (err != nil){
        log.Fatal(err)
    }
    defer db.Close()

    fmt.Println(fmt.Sprintf("Staring application on port %s", portNumber))

    srv := &http.Server{
        Addr: portNumber,
        Handler: route,
    }

    err = srv.ListenAndServe()
    if err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, when we run go run main.go and test on Postman, we get the following result:

Post request to create a user
Get Request to get a user by Id

Writing Tests

Here comes the fun part 🤩.
The first thing we want to do is create mock implementations of our database logic functions, the UserRepo functions in this case. So, we would create a new file test-repo.go under the package repository with the following steps.
1) Declare the package repository and import the necessary packages.

package repository

import (
    "database/sql"
    "errors"

    "github.com/orololuwa/reimagined-robot/models"
)
Enter fullscreen mode Exit fullscreen mode

2) Next, we create a test repo struct and an initialization function to implement the UserRepo interface we previously had.

type testUserDBRepo struct {
    DB *sql.DB
}

func NewUserTestingDBRepo() UserRepo {
    return &testUserDBRepo{
    }
}
Enter fullscreen mode Exit fullscreen mode

3) Next, we create the mock implementation of the UserRepo functions as thus;

func (m *testUserDBRepo) CreateAUser(user models.User) (int, error){
    var newId int

    if user.Password == "invalid"{
        return newId, errors.New("CreateAUser: DB repo fail")
    }

    return newId, nil
}

func (m *testUserDBRepo) GetAUser(id int)(models.User, error){
    var user models.User

    if id == 0{
        return user, errors.New("GetAUser: DB repo fail")
    }

    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

In the CreateAUser function above, we create a scenario where the function returns an error. Likewise, the GetAUser function.
Now that we've mocked our UserRepo functions, we would go ahead and write tests for the handlers.

The Handlers
In the handlers package/folder, we would create a file called setup_test.go to set up what we would need in the actual test. In this file, we would go ahead and initialize a test db repo with the mocked functions. Your test files have to end with the suffix _test.go to allow the go compiler to see it in the testing environment. In the file, we would put the following code;

package handlers

import (
    "os"
    "testing"

    "github.com/orololuwa/reimagined-robot/repository"
)

func NewTestingHandler() {
    r := &Repository{
        User: repository.NewUserTestingDBRepo(),
    }
    Repo = r
}

func TestMain(m *testing.M){
    NewTestingHandler()
    os.Exit(m.Run())
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the TestMain function is going to run before any other test functions we'd create later. It receives a pointer to testing.M, which makes available to us, a Run function.
Next, we would go ahead and install an external package for creating mocking data.

go get -u github.com/go-faker/faker/v4
Enter fullscreen mode Exit fullscreen mode

Now we create a new file; handlers_test.go where we write the tests for the handlers as thus:
1) Declare the package and import every other package that would be used.

package handlers

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/go-faker/faker/v4"
)
Enter fullscreen mode Exit fullscreen mode

2) Next, we create a TestCreateAUser function to test the CreateAUser handler.

func TestCreateAUser(t *testing.T){
    type UserBody struct {
        FirstName string `json:"firstName" faker:"first_name"`
        LastName string `json:"lastName" faker:"last_name"`
        Email string `json:"email" faker:"email"`
        Password string `json:"password" faker:"password"`
    }

    body := UserBody{}
    err := faker.FakeData(&body)
    if err != nil {
        t.Log(err)
    }

    jsonBody, err := json.Marshal(body)
    if err != nil {
        t.Log("Error:", err)
        return
    }

    // Test for success
    req, _ := http.NewRequest("POST", "/user", bytes.NewBuffer(jsonBody))
    rr := httptest.NewRecorder()

    handler := http.HandlerFunc(Repo.CreateAUser)
    handler.ServeHTTP(rr, req)

    if rr.Code != http.StatusCreated {
        t.Errorf("CreateAUser handler returned wrong response code: got %d, wanted %d", rr.Code, http.StatusCreated)
    }

    // Test for missing request body
    req, _ = http.NewRequest("POST", "/user", bytes.NewBuffer([]byte(``)))
    rr = httptest.NewRecorder()

    handler = http.HandlerFunc(Repo.CreateAUser)
    handler.ServeHTTP(rr, req)

    if rr.Code != http.StatusInternalServerError {
        t.Errorf("CreateAUser handler returned wrong response code for missing body: got %d, wanted %d", rr.Code, http.StatusInternalServerError)
    }

    // Test for failed DB insert
    body.Password = "invalid"
    jsonBody, err = json.Marshal(body)
    if err != nil {
        t.Log("Error:", err)
        return
    }

    req, _ = http.NewRequest("POST", "/user", bytes.NewBuffer(jsonBody))
    rr = httptest.NewRecorder()

    handler = http.HandlerFunc(Repo.CreateAUser)
    handler.ServeHTTP(rr, req)

    if rr.Code != http.StatusBadRequest {
        t.Errorf("CreateAUser handler returned wrong response code for failed UserRepo db function: got %d, wanted %d", rr.Code, http.StatusBadRequest)
    }
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above, we declare the Userbody struct with struct tags including the JSON and Faker metadata. We create mock data into the body variable with the Faker package and encode it into JSON into the jsonBody variable. We create instances of the HTTP Request and ResponseRecorder and test them according to different cases. In this, we tested for cases of a successful request, missing request body, and failed DB operation.
3) Still in the same file, we create a TestGetAUser function to test the GetAUser handler

func TestGetAUser(t *testing.T){
    // test for success
    req, _ := http.NewRequest("GET", "/user/1", nil)
    req.Header.Set("Content-Type", "application/json")
    req.RequestURI = "/user/1"  
    res := httptest.NewRecorder()

    handler := http.HandlerFunc(Repo.GetAUser)  
    handler.ServeHTTP(res, req)

    if res.Code != http.StatusOK {
        t.Errorf("GetAUser handler returned wrong response code: got %d, wanted %d", res.Code, http.StatusOK)
    }

    // test valid id in the path variable
    req, _ = http.NewRequest("GET", "/room", nil)
    req.Header.Set("Content-Type", "application/json")
    req.RequestURI = "/room/one"

    res = httptest.NewRecorder()

    handler = http.HandlerFunc(Repo.GetAUser)

    handler.ServeHTTP(res, req)

    if res.Code != http.StatusInternalServerError {
        t.Errorf("GetAUser handler returned wrong response code for invalid query param 'id': got %d, wanted %d", res.Code, http.StatusInternalServerError)
    }

    // test for failed db operation
    req, _ = http.NewRequest("GET", "/room", nil)
    req.Header.Set("Content-Type", "application/json")  
    req.RequestURI = "/room/0"


    res = httptest.NewRecorder()

    handler = http.HandlerFunc(Repo.GetAUser)

    handler.ServeHTTP(res, req)

    if res.Code != http.StatusNotFound {
        t.Errorf("GetAUser handler returned wrong response code for failed UserRepo function: got %d, wanted %d", res.Code, http.StatusNotFound)
    }
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above, we create instances of the HTTP Request and ResponseRecorder and test for cases where the request is successful, the id is missing or invalid, and a failed DB operation.

The main file
Testing the main file is quite easy. We create a main_test.go file in the root under the main package and try to run the "run" function as thus:

package main

import "testing"

func TestRun(t *testing.T){
    _, _, err := run();
    if err != nil {
        t.Error("failed run")
    }
}
Enter fullscreen mode Exit fullscreen mode

Running the test commands
There are different ways to run the test.
1) You can do so with a simple test command without any flags

go test ./...
Enter fullscreen mode Exit fullscreen mode

Result of running go test ./...

2) You can run it with the -v (verbose) flag to get more details about which test functions passed or failed.

go test -v ./...
Enter fullscreen mode Exit fullscreen mode

Result of running go test -v ./...

3) Running it with -cover flag to get the percentage of obvious test cases that were covered in the test.

go test -cover ./...
Enter fullscreen mode Exit fullscreen mode

Result of running go test -cover ./...

4) Running it to get the coverage output as an HTML file.

go test -coverprofile=coverage.out  ./... && go tool cover -html=coverage.out
Enter fullscreen mode Exit fullscreen mode

Part result of running go test -coverprofile=coverage.out  ./... && go tool cover -html=coverage.out

Conclusion

In this article, we explored a basic way to set up handlers and write proper tests. You can build on it as your code gets robust and complicated. The source code is available on my Github

Top comments (0)