Backend development in Golang — REST API testing (Part 2)

Joshua Etim
5 min readAug 26, 2024

--

In the previous part of this series, I emphasized the importance of service tests (which we can technically refer to as integration tests) in developing our web services. Service tests bring us closest to the client’s perspective, and thus provide a guarantee that calls to our endpoints will work as expected. Unit tests, on the other hand, while essential for code reliability and maintainability, may not provide such a guarantee.

For this part, we will build and test a simple web service that manages records for a small business. The architecture and structure are flat and simple for demonstration purposes, so it’s easy to pick up if you’re a beginner in Golang. Major parts of the code can be found in the repository, this article only highlights relevant code snippets.

The Code

It exposes four endpoints — to create a record, update it, delete it, and retrieve all records. The handler code can be seen below, to get the full code, you can check out my repository here:

package main

import (
"log"
"net/http"

"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)

type RecordHandler struct {
db *gorm.DB
rdb *redis.Client
logger *log.Logger
}

const RecordsKey = "records"

type ResponseMsg map[string]string

func NewRecordHandler(db *gorm.DB, rdb *redis.Client, logger *log.Logger) *RecordHandler {
return &RecordHandler{
db: db,
rdb: rdb,
logger: logger,
}
}

func (rh *RecordHandler) GetAll(w http.ResponseWriter, r *http.Request) {
var records []Record
err := rh.db.Find(&records).Error
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

ToJSON(w, records, http.StatusOK)
}

func (rh *RecordHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createRecordRequest
if err := FromJSON(r.Body, &req); err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

record := Record{
Data: req.Data,
Location: req.Location,
OrderDate: req.OrderDate,
Forward: req.Forward,
}

// database operation
err := rh.db.Create(&record).Error
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}
// add count to redis
err = rh.rdb.Incr(r.Context(), RecordsKey).Err()
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

// return data
ToJSON(w, record, http.StatusOK)
}

func (rh *RecordHandler) Update(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
recordId, err := uuid.Parse(id)
if err != nil {
ToJSON(w, ResponseMsg{"error": "id invalid: " + err.Error()}, http.StatusBadRequest)
return
}

var req createRecordRequest
if err := FromJSON(r.Body, &req); err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

record := Record{
ID: recordId,
Data: req.Data,
Location: req.Location,
OrderDate: req.OrderDate,
Forward: req.Forward,
}

// database operation
err = rh.db.Updates(&record).Error
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

ToJSON(w, record, http.StatusOK)
}

func (rh *RecordHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
recordId, err := uuid.Parse(id)
if err != nil {
ToJSON(w, ResponseMsg{"error": "id invalid: " + err.Error()}, http.StatusBadRequest)
return
}

err = rh.db.Delete(&Record{ID: recordId}).Error
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

err = rh.rdb.Decr(r.Context(), RecordsKey).Err()
if err != nil {
ToJSON(w, ResponseMsg{"error": err.Error()}, http.StatusInternalServerError)
return
}

ToJSON(w, ResponseMsg{"message": "record deleted"}, http.StatusOK)
}

As seen in the code snippet (and fully in the repository), our handler uses a Redis client, a database client, and a logger. How do we test this?

Testing the service

In service testing, we want to be sure a request will perform as expected. So for this scenario, we will create a test database, as well as make use of a Redis instance. Since these requirements are real (not mocks), we will make sure to flush them as soon as testing is over. The code snippet below demonstrates how such testing is done:

type JSONData map[string]interface{}

type testEngine struct {
db *gorm.DB
rdb *redis.Client
}

func TestCreateRecord(t *testing.T) {
engine, flush := setup()
defer flush()

logger := log.Default()
handler := NewRecordHandler(engine.db, engine.rdb, logger)

recorder := httptest.NewRecorder()
reqData := JSONData{
"data": "Record Data",
"order_date": "2024-04-01T15:04:05Z",
"forward": true,
"location": "Port 1",
}
content, err := json.Marshal(reqData)
if err != nil {
t.Fatal(err)
}
data := bytes.NewBuffer(content)
request, err := http.NewRequest("POST", "/records", data)
if err != nil {
t.Fatal(err)
}

handler.Create(recorder, request)
res := recorder.Result()
assert.Equal(t, res.StatusCode, http.StatusCreated)

var count int64
err = handler.db.Model(&Record{}).Count(&count).Error
assert.Nil(t, err)
assert.EqualValues(t, count, 1)
}

func TestGetAllRecords(t *testing.T) {
engine, flush := setup()
defer flush()

count := 50
records, err := populateRecord(engine, count)
if err != nil {
t.Fatal(err)
}

logger := log.Default()
handler := NewRecordHandler(engine.db, engine.rdb, logger)

recorder := httptest.NewRecorder()
request := httptest.NewRequest("GET", "/records", nil)
handler.GetAll(recorder, request)

result := recorder.Result()
assert.Equal(t, result.StatusCode, http.StatusOK)

var response []JSONData
if err := json.NewDecoder(result.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if err := result.Body.Close(); err != nil {
t.Fatal(err)
}

assert.Equal(t, len(response), count)
assert.EqualValues(t, records[0].Data, response[0]["data"])
assert.EqualValues(t, records[count-1].Data, response[count-1]["data"])
}

Other tests can be seen in the repository here: https://github.com/joshuaetim/biznet/blob/main/handlers_test.go

The key resource here is the httptest package which lets us test our HTTP services. In this case, we set up our Postgres (Gorm) connection and the Redis connection, just like the actual application instance. The difference is we are using an isolated instance, in the case of the database, a test database, and in the case of Redis, selecting a Redis DB (8). We then write a small script to flush our databases. The setup and flush snippet can be seen below:

func setup() (engine *testEngine, callback func()) {
if err := godotenv.Load("test.env"); err != nil {
log.Fatal(err)
}

engine = &testEngine{
db: setupDB(),
rdb: setupRedis(),
}

// flush databases
return engine, func() {
err := flushDB(engine.db)
if err != nil {
log.Fatal(err)
}

err = flushRDB(engine.rdb)
if err != nil {
log.Fatal(err)
}
}
}

func setupDB() *gorm.DB {
db := SQL(os.Getenv("PG_DSN"))
return db
}

func setupRedis() *redis.Client {
redis_db, err := strconv.Atoi(os.Getenv("REDIS_DB"))
if err != nil {
log.Fatalf("redis db parsing error: %v", err)
}
rdb := Redis(redis_db)
return rdb
}

func flushDB(db *gorm.DB) error {
return db.Migrator().DropTable(&Record{})
}

func flushRDB(rdb *redis.Client) error {
return rdb.FlushDB(context.Background()).Err()
}

To populate our database, I’m making use of the Faker library, with the option to set the number of items to generate.

func populateRecord(engine *testEngine, count int) ([]Record, error) {
var records []Record
for range count {
id := uuid.New()
records = append(records, Record{
ID: id,
Data: faker.Word(),
OrderDate: time.Now(),
Forward: true,
Location: faker.GetCountryInfo().Name,
})
}
return records, engine.db.CreateInBatches(records, 30).Error
}

Conclusion

A simple CI/CD pipeline can be set up to automate the tests on delivery, and extra test cases can be added to cover more scenarios. With tests like these, you can be sure your endpoints will behave as expected in your production environment.

Let me know what you think, and please clap and share so others see this.

--

--

Joshua Etim
Joshua Etim

Written by Joshua Etim

I share my programming experiences. For inquiries, you can reach out at jetimworks@gmail.com

No responses yet