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
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
}
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)
}
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
}
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"
)
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
}
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)
}
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)
}
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"
)
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
}
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)
}
}
Now, when we run go run main.go
and test on Postman, we get the following result:
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"
)
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{
}
}
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
}
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())
}
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
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"
)
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)
}
}
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)
}
}
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")
}
}
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 ./...
2) You can run it with the -v (verbose) flag to get more details about which test functions passed or failed.
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 ./...
4) Running it to get the coverage output as an HTML file.
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)