Monitoring the Golang App with Prometheus, Grafana, New Relic and Sentry

Mert ÇAKMAK
15 min readMar 31, 2024

--

Hello, I will talk about how to monitor and log the Go service. We will be using New Relic, Sentry, Prometheus and Grafana.

Goals

  • In middleware, send request log and response log to New Relic with Request ID.
  • Send error that captured exception to Sentry with Request ID.
  • Mocking (DB, Repository, Usecase)
  • Unit test.
  • Monitoring the service with Prometheus and Grafana.
  • Crud operations.
https://www.servicenow.com/content/dam/servicenow-assets/public/en-us/images/company-library/what-is-pages/what-is-diff-observability-monitoring.png.thumb.320.320.png

Tech Stack:

  • Golang
  • Prometheus
  • Grafana
  • New Relic
  • Sentry

What is New Relic?

New Relic is an observability platform that helps you build better software. You can bring in data from any digital source so that you can fully understand your system, analyze that data efficiently, and respond to incidents before they become problems. As extensive as the capabilities of New Relic are, you can get started with the platform by following a three-step procedure.

What is Sentry?

Sentry is a software monitoring tool that helps developers identify and fix code-related issues. From error tracking to performance monitoring, Sentry provides code-level observability that makes it easy to diagnose issues and learn continuously about your application code health.

Prometheus & Grafana

I have described Prometheus and Grafana in the below post.

Run Docker Containers

We will create docker-compose file and run containers.

docker-compose.yaml

version: '3'
services:

postgre-db:
image: "postgres:15"
container_name: postgre-db
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- "5433:5432"

prometheus:
container_name: prometheus-service
image: prom/prometheus
restart: always
extra_hosts:
- host.docker.internal:host-gateway
command:
- --config.file=/etc/prometheus/prometheus.yml
volumes:
- ./docker/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"

grafana:
container_name: grafana-service
image: grafana/grafana
ports:
- "3000:3000"

prometheus.yml

./docker/prometheus.yml

global:
scrape_interval: 5s
evaluation_interval: 5s

scrape_configs:
- job_name: "go-app"
static_configs:
- targets: ['host.docker.internal:8080']

Run the below command.

docker-compose up -d

Creating Go App

  • Firstly, we install the following packages with go get command.
go get github.com/gin-gonic/gin
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promauto
go get github.com/prometheus/client_golang/prometheus/promhttp
go get github.com/getsentry/sentry-go
go get github.com/newrelic/go-agent/v3/integrations/nrgin
go get go.uber.org/zap
go get gorm.io/driver/postgres
go get gorm.io/gorm
go get github.com/sethvargo/go-envconfig
go get github.com/stretchr/testify

Let’s Code Go App

main.go

package main

import (
"github.com/gin-gonic/gin"
"github.com/newrelic/go-agent/v3/newrelic"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go-app/config"
"go-app/database"
"go-app/middleware"
"go-app/user"
)

var logger = config.ZapTestConfig()

func main() {

// Postgres Config & Migration
db := config.ConnectPostgres()
database.Migrate(db)
defer func() {
dbInstance, _ := db.DB()
_ = dbInstance.Close()
}()

// Sentry Config, New Relic Config & Zap Config
config.SentryConfig()
newRelicConfig := config.NewRelicConfig()
logger = config.ZapConfig(newRelicConfig)

// User Repository, User UseCase & User Handler
userRepo := user.NewUserRepository(db)
userUseCase := user.NewUserUseCase(userRepo, logger)
userHandler := user.NewUserHandler(userUseCase, logger)

// Setup Router
router := setupRouter(newRelicConfig, userHandler)
router.Run(":8080")
}

func setupRouter(newRelicConfig *newrelic.Application, handler *user.Handler) *gin.Engine {
router := gin.Default()

// Middlewares
_middleware := middleware.NewMiddleware(newRelicConfig, logger)
router.Use(_middleware.NewRelicMiddleWare())
router.Use(_middleware.SentryMiddleware())
router.Use(_middleware.LogMiddleware)

router.GET("/metrics", gin.WrapH(promhttp.Handler()))
v1 := router.Group("/api/v1/users")
v1.POST("", handler.CreateUser)
v1.GET("/:id", handler.GetUserById)
v1.PUT("", handler.UpdateUser)
v1.DELETE("/:id", handler.DeleteUserById)
return router
}

/metrics: Prometheus will poll this endpoint to handle metrics.

Config Package

Create the Sentry, New Relic, Zap, and Postgres config files in the package.

env_config.go

go-envconfig: This package helps in parsing environment variables into a Go struct. It simplifies reading configurations from the environment.

package config

import (
"context"
"github.com/sethvargo/go-envconfig"
"log"
"sync"
)

var (
cfg AppConfig
configOnce sync.Once
)

func config() AppConfig {
configOnce.Do(func() {
ctx := context.Background()
if err := envconfig.Process(ctx, &cfg); err != nil {
log.Fatal(err)
}
log.Println("Environments initialized.")
})
return cfg
}

type AppConfig struct {
Database *Database
NewRelic *NewRelic
Sentry *Sentry
}

type Database struct {
Host string `env:"POSTGRES_HOST, default=localhost"`
Username string `env:"POSTGRES_USERNAME, default=postgres"`
Password string `env:"POSTGRES_PASSWORD, default=postgres"`
Port string `env:"POSTGRES_PORT, default=5432"`
DatabaseName string `env:"DATABASE_NAME, default=postgres"`
}

type NewRelic struct {
AppName string `env:"APP_NAME, default=go-app"`
License string `env:"NEW_RELIC_LICENSE"`
}

type Sentry struct {
Dsn string `env:"SENTRY_DSN"`
}

new_relic.go

package config

import (
"fmt"
"github.com/newrelic/go-agent/v3/newrelic"
"os"
)

func NewRelicConfig() *newrelic.Application {
app, err := newrelic.NewApplication(
newrelic.ConfigAppName(config().NewRelic.AppName),
newrelic.ConfigLicense(config().NewRelic.License),
newrelic.ConfigCodeLevelMetricsEnabled(true),
newrelic.ConfigAppLogForwardingEnabled(true),
)
if nil != err {
fmt.Printf("New Relic initialization failed: %v", err)
os.Exit(1)
}

return app
}

sentry.go

package config

import (
"fmt"
"github.com/getsentry/sentry-go"
"os"
)

func SentryConfig() {
if err := sentry.Init(sentry.ClientOptions{
Dsn: config().Sentry.Dsn,
EnableTracing: true,
TracesSampleRate: 1.0,
}); err != nil {
fmt.Printf("Sentry initialization failed: %v", err)
os.Exit(1)
}
}

zap.go

package config

import (
"github.com/newrelic/go-agent/v3/integrations/logcontext-v2/nrzap"
"github.com/newrelic/go-agent/v3/newrelic"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
)

func ZapConfig(app *newrelic.Application) *zap.Logger {
core := zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(os.Stdout), zap.InfoLevel)

backgroundCore, err := nrzap.WrapBackgroundCore(core, app)
if err != nil && err != nrzap.ErrNilApp {
panic(err)
}

return zap.New(backgroundCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
}

func ZapTestConfig() *zap.Logger {
logger, err := zap.NewProduction()
if err != nil {
panic(err.Error())
}
return logger
}

postgre.go

package config

import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"log"
"sync"
)

var once = sync.Once{}

func ConnectPostgres() *gorm.DB {
var postgresDb *gorm.DB
once.Do(func() {
dsn := getConnectionString()
var err error
postgresDb, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalln(err)
}
log.Println("Creating single postgres db instance now.")
})
return postgresDb
}

func getConnectionString() string {
host := config().Database.Host
user := config().Database.Username
password := config().Database.Password
dbname := config().Database.DatabaseName
port := config().Database.Port

connectionSting := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Europe/Istanbul", host, user, password, dbname, port)
return connectionSting
}
  • gorm.io/driver/postgres: A PostgreSQL driver, needed to communicate with PostgreSQL databases.
  • gorm.io/gorm: ORM library for Go.
  • once: An instance of sync.Once, ensuring that the block of code that initializes the database connection runs only once, no matter how many times ConnectPostgres is called.

User Package

Create the use_case.go, repository.go, handler.go and test files in the package.

repository.go

package user

import (
"errors"
"fmt"
"go-app/domain"
"gorm.io/gorm"
)

//go:generate mockgen -destination=../mocks/mockUserRepository.go -package=mocks go-app/domain UserRepository
type userRepository struct {
db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &userRepository{db: db}
}

func (r *userRepository) CreateUser(user domain.User) (domain.User, *domain.AppError) {
err := r.db.Create(&user).Error
if err != nil {
return user, domain.NewUnexpectedError(err.Error())
}
return user, nil
}

func (r *userRepository) GetUserById(id uint) (domain.User, *domain.AppError) {
var user domain.User
err := r.db.Where("id = ?", id).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
errStr := fmt.Sprintf("User not found, ID: %d", id)
return user, domain.NewNotFoundError(errStr)
}

if err != nil {
return user, domain.NewUnexpectedError(err.Error())
}

return user, nil
}

func (r *userRepository) UpdateUser(user domain.User) (domain.User, *domain.AppError) {
err := r.db.Save(&user).Error
if err != nil {
return user, domain.NewUnexpectedError(err.Error())
}
return user, nil
}

func (r *userRepository) DeleteUserById(id uint) *domain.AppError {
err := r.db.Delete(&domain.User{}, id).Error
if err != nil {
return domain.NewUnexpectedError(err.Error())
}
return nil
}

//go:generate mockgen: The purpose here is to automatically generate a mock version of the UserRepository interface. This mock is placed in the ../mocks directory, with the filename mockUserRepository.go.

use_case.go

package user

import (
"fmt"
"go-app/domain"
"go.uber.org/zap"
"time"
)

//go:generate mockgen -destination=../mocks/mockUserUsecase.go -package=mocks go-app/domain UserUseCase
type userUseCase struct {
repo domain.UserRepository
logger *zap.Logger
}

func NewUserUseCase(repo domain.UserRepository, logger *zap.Logger) domain.UserUseCase {
return &userUseCase{repo: repo, logger: logger}
}

func (u *userUseCase) CreateUser(user domain.User) (domain.User, *domain.AppError) {
user.CreatedDate = time.Now()
if user.Name == "" {
err := domain.NewValidationError("The name should not be empty.")
u.logger.Error(err.Message)
return user, err
}

createdUser, err := u.repo.CreateUser(user)
if err != nil {
u.logger.Error(err.Message)
return domain.User{}, err
}

u.logger.Info(fmt.Sprintf("User created. ID: %d", createdUser.ID))
return createdUser, nil
}

func (u *userUseCase) GetUserById(id uint) (domain.User, *domain.AppError) {
user, err := u.repo.GetUserById(id)
if err != nil {
u.logger.Error(err.Message)
return user, err
}

return user, nil
}

func (u *userUseCase) UpdateUser(user domain.User) (domain.User, *domain.AppError) {
updatedUser, err := u.repo.UpdateUser(user)
if err != nil {
u.logger.Error(err.Message)
return updatedUser, err
}
return updatedUser, nil
}

func (u *userUseCase) DeleteUserById(id uint) *domain.AppError {
err := u.repo.DeleteUserById(id)
if err != nil {
u.logger.Error(err.Message)
return err
}
return err
}

//go:generate mockgen: The purpose here is to automatically generate a mock version of the UserUseCase interface. This mock is placed in the ../mocks directory, with the filename mockUserUsecase.go.

Run the below command.

go generate ./…

This command generates mock files.

handler.go

We create an HTTP server using Gin.

package user

import (
"errors"
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-gonic/gin"
"go-app/domain"
"go.uber.org/zap"
"net/http"
"strconv"
)

type Handler struct {
userUseCase domain.UserUseCase
logger *zap.Logger
}

func NewUserHandler(userUseCase domain.UserUseCase, logger *zap.Logger) *Handler {
return &Handler{userUseCase: userUseCase, logger: logger}
}

func (h *Handler) CreateUser(c *gin.Context) {
if hub := sentrygin.GetHubFromContext(c); hub != nil {
var user domain.User

if c.ShouldBind(&user) != nil {
c.JSON(400, domain.NewBadRequestError("bad request"))
return
}

createUser, err := h.userUseCase.CreateUser(user)
if err != nil {
hub.CaptureException(errors.New(err.Message))
c.JSON(err.Code, err.AsMessageError())
return
}
c.JSON(http.StatusCreated, createUser)
}
}

func (h *Handler) GetUserById(c *gin.Context) {
if hub := sentrygin.GetHubFromContext(c); hub != nil {
idParam := c.Param("id")
id, _ := strconv.ParseInt(idParam, 10, 64)

user, err := h.userUseCase.GetUserById(uint(id))
if err != nil {
hub.CaptureException(errors.New(err.Message))
c.JSON(err.Code, err.AsMessageError())
return
}
c.JSON(http.StatusOK, user)
}
}

func (h *Handler) UpdateUser(c *gin.Context) {
if hub := sentrygin.GetHubFromContext(c); hub != nil {
var user domain.User
if c.ShouldBind(&user) != nil {
c.JSON(400, domain.NewBadRequestError("bad request"))
}

updatedUser, err := h.userUseCase.UpdateUser(user)
if err != nil {
hub.CaptureException(errors.New(err.Message))
c.JSON(err.Code, err.AsMessageError())
return
}
c.JSON(http.StatusOK, updatedUser)
}
}

func (h *Handler) DeleteUserById(c *gin.Context) {
if hub := sentrygin.GetHubFromContext(c); hub != nil {
idParam := c.Param("id")
id, _ := strconv.ParseInt(idParam, 10, 64)

err := h.userUseCase.DeleteUserById(uint(id))
if err != nil {
hub.CaptureException(errors.New(err.Message))
c.JSON(err.Code, err.AsMessageError())
return
}
c.Status(http.StatusNoContent)
}
}

hub.CaptureException(): If it returns an error, it captures the exception and sends it to Sentry.

Middleware Package

Create the middleware.go file in the package.

middleware.go

package middleware

import (
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/newrelic/go-agent/v3/integrations/nrgin"
"github.com/newrelic/go-agent/v3/newrelic"
"go-app/logging"
"go.uber.org/zap"
"net/http"
)

type middleware struct {
newRelicConfig *newrelic.Application
logger *zap.Logger
}

func NewMiddleware(newRelicConfig *newrelic.Application, logger *zap.Logger) middleware {
return middleware{newRelicConfig: newRelicConfig, logger: logger}
}

func (m middleware) NewRelicMiddleWare() gin.HandlerFunc {
return nrgin.Middleware(m.newRelicConfig)
}

func (m middleware) SentryMiddleware() gin.HandlerFunc {
return sentrygin.New(sentrygin.Options{Repanic: true})
}

func (m middleware) LogMiddleware(ctx *gin.Context) {
var responseBody = logging.HandleResponseBody(ctx.Writer)
var requestBody = logging.HandleRequestBody(ctx.Request)
requestId := uuid.NewString()

if hub := sentrygin.GetHubFromContext(ctx); hub != nil {
hub.Scope().SetTag("requestId", requestId)
ctx.Writer = responseBody
}

ctx.Next()

logMessage := logging.FormatRequestAndResponse(ctx.Writer, ctx.Request, responseBody.Body.String(), requestId, requestBody)

if logMessage != "" {
if isSuccessStatusCode(ctx.Writer.Status()) {
m.logger.Info(logMessage)
} else {
m.logger.Error(logMessage)
}
}
}

func isSuccessStatusCode(statusCode int) bool {
switch statusCode {
case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent:
return true
default:
return false
}
}

NewRelicMiddleware(): Middleware for New Relic.

SentryMiddleware(): Middleware for Sentry.

LogMiddleware(): This middleware sends HTTP requests and HTTP response logs to New Relic. Generates a request ID, and this request ID is set as a tag. In this way, a relationship is established between the New Relic log and the Sentry error.

Let’s Write Unit Test

go-sql-mock: sqlmock is a mock library implementing sql/driver. Which has one and only purpose — to simulate any SQL driver behavior in tests, without needing a real database connection. It helps to maintain correct TDD workflow.

stretchr/testify: A toolkit with common assertions and mocks that plays nicely with the standard library

repository_test.go

package user

import (
"errors"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"go-app/domain"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"strings"
"testing"
"time"
)

func mockRepositorySetup() (*gorm.DB, sqlmock.Sqlmock) {
db, mock, err := sqlmock.New()
if err != nil {
panic("Failed to create sqlmock.")
}

dialector := postgres.New(postgres.Config{
DSN: "sqlmock_db_0",
DriverName: "postgres",
Conn: db,
PreferSimpleProtocol: true,
})
gormDB, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
panic("Failed to open gorm db.")
}

return gormDB, mock
}

func Test_Should_Create_User_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{Name: "John Doe", Age: 30, CreatedDate: time.Now()}

// WHEN
mock.ExpectBegin()
mock.ExpectQuery(`INSERT INTO "users"`).
WithArgs(user.Name, user.Age, user.CreatedDate).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectCommit()

result, err := repo.CreateUser(user)

// THEN
assert.Nil(t, err)
assert.NotNil(t, result.ID)
}

func Test_Should_Return_Err_When_Invoke_Create_User_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{Name: "John Doe", Age: 30, CreatedDate: time.Now()}
gormErr := errors.New("Unexpected Error")
unexpectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
mock.ExpectBegin()
mock.ExpectQuery(`INSERT INTO "users"`).
WithArgs(user.Name, user.Age, user.CreatedDate).
WillReturnError(gormErr)
mock.ExpectCommit()

_, err := repo.CreateUser(user)

// THEN
assert.NotNil(t, err)
assert.Equal(t, unexpectedErr.Code, err.Code)
assert.True(t, strings.Contains(err.Message, unexpectedErr.Message), "Should contains Unexpected Error")
}

func Test_Should_Get_User_By_Id_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{ID: 1, Name: "John Doe", Age: 30, CreatedDate: time.Now()}

// WHEN
expectedSQL := "SELECT (.+) FROM \"users\" WHERE id =(.+)"
mock.ExpectQuery(expectedSQL).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "age", "created_date"}).
AddRow(user.ID, user.Name, user.Age, user.CreatedDate))

result, err := repo.GetUserById(user.ID)

// THEN
assert.Nil(t, err)
assert.Equal(t, user.Name, result.Name)
}

func Test_Should_Return_Not_Found_Error_When_Invoke_Get_User_By_Id_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
var id uint = 1
expectedError := domain.NewNotFoundError("User not found, ID: 1")

// WHEN
expectedSQL := "SELECT (.+) FROM \"users\" WHERE id =(.+)"
mock.ExpectQuery(expectedSQL).WillReturnError(gorm.ErrRecordNotFound)

_, err := repo.GetUserById(id)

// THEN
assert.NotNil(t, err)
assert.Equal(t, expectedError.Message, err.Message)
assert.Equal(t, expectedError.Code, err.Code)
}

func Test_Should_Return_Unexpected_Error_When_Invoke_Get_User_By_Id_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
var id uint = 1
expectedError := domain.NewUnexpectedError("Unexpected Err")

// WHEN
expectedSQL := "SELECT (.+) FROM \"users\" WHERE id =(.+)"
mock.ExpectQuery(expectedSQL).WillReturnError(gorm.ErrNotImplemented)

_, err := repo.GetUserById(id)

// THEN
assert.NotNil(t, err)
assert.Equal(t, expectedError.Code, err.Code)
}

func Test_Should_Update_User_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{ID: 1, Name: "Edit User", Age: 29, CreatedDate: time.Now()}

// WHEN
updUserSQL := "UPDATE \"users\" SET .+"
mock.ExpectBegin()
mock.ExpectExec(updUserSQL).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

updateUser, err := repo.UpdateUser(user)

// THEN
assert.Nil(t, err)
assert.Equal(t, user.Name, updateUser.Name)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Update_User_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{ID: 1}
gormErr := errors.New("Unexpected Error")
unexpectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
mock.ExpectBegin()
mock.ExpectExec("UPDATE \"users\" SET .+").
WillReturnError(gormErr)
mock.ExpectCommit()

_, err := repo.UpdateUser(user)

// THEN
assert.NotNil(t, err)
assert.Equal(t, unexpectedErr.Code, err.Code)
assert.True(t, strings.Contains(err.Message, unexpectedErr.Message), "Should contains Unexpected Error")
}

func Test_Should_Delete_User_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{ID: 1}

// WHEN
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM \"users\" WHERE (.+)$").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err := repo.DeleteUserById(user.ID)

// THEN
assert.Nil(t, err)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Delete_User_By_Id_With_Mock_Db(t *testing.T) {
db, mock := mockRepositorySetup()
repo := NewUserRepository(db)

// GIVEN
user := domain.User{ID: 1}
gormErr := errors.New("Unexpected Error")
unexpectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM \"users\" WHERE (.+)$").
WillReturnError(gormErr)
mock.ExpectCommit()

err := repo.DeleteUserById(user.ID)

// THEN
assert.NotNil(t, err)
assert.Equal(t, unexpectedErr.Code, err.Code)
assert.True(t, strings.Contains(err.Message, unexpectedErr.Message), "Should contains Unexpected Error")
}

use_case_test.go

package user

import (
"fmt"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"go-app/config"
"go-app/domain"
"go-app/mocks"
"testing"
)

var (
_userMockRepo *mocks.MockUserRepository
_userUseCase domain.UserUseCase
)

func mockUseCaseSetup(t *testing.T) {
c := gomock.NewController(t)
defer c.Finish()

// Mock UserRepository
_userMockRepo = mocks.NewMockUserRepository(c)

logger := config.ZapTestConfig()
_userUseCase = NewUserUseCase(_userMockRepo, logger)
}

func Test_Should_Create_User_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
user := domain.User{Name: "test", Age: 18}
expectedUser := domain.User{ID: 1, Name: "test", Age: 18}

// WHEN
_userMockRepo.EXPECT().CreateUser(gomock.Any()).Return(expectedUser, nil)
res, err := _userUseCase.CreateUser(user)

// THEN
assert.Nil(t, err)
assert.Equal(t, expectedUser.Name, res.Name)
}

func Test_Should_Return_Validation_Err_When_Invoke_Create_User_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
user := domain.User{Age: 18}
validationErr := domain.NewValidationError("The name should not be empty.")

// WHEN
_, err := _userUseCase.CreateUser(user)

// THEN
assert.NotNil(t, err)
assert.Equal(t, validationErr.Message, err.Message)
}

func Test(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
user := domain.User{Name: "test-user", Age: 18}
expectedErr := domain.NewUnexpectedError("Unexpected error.")

// WHEN
_userMockRepo.EXPECT().CreateUser(gomock.Any()).Return(domain.User{}, expectedErr)
_, err := _userUseCase.CreateUser(user)

// THEN
assert.NotNil(t, err)
assert.Equal(t, expectedErr.Message, err.Message)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Create_User_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
expectedUser := domain.User{ID: 1, Name: "test", Age: 18}
var id uint = 1

// WHEN
_userMockRepo.EXPECT().GetUserById(id).Return(expectedUser, nil)
res, err := _userUseCase.GetUserById(id)

// THEN
assert.Nil(t, err)
assert.Equal(t, expectedUser.Name, res.Name)
}

func Test_Should_Return_Not_Found_Err_When_Invoke_Get_User_By_Id_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
var id uint = 1
errStr := fmt.Sprintf("User not found, ID: %d", id)
notFoundErr := domain.NewNotFoundError(errStr)

// WHEN
_userMockRepo.EXPECT().GetUserById(gomock.Any()).Return(domain.User{}, notFoundErr)
_, err := _userUseCase.GetUserById(id)

// THEN
assert.NotNil(t, err)
assert.Equal(t, notFoundErr.Message, err.Message)
}

func Test_Should_Update_User_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
user := domain.User{ID: 1, Name: "updated-user", Age: 18}
expectedUser := domain.User{ID: 1, Name: "updated-user", Age: 18}

// WHEN
_userMockRepo.EXPECT().UpdateUser(gomock.Any()).Return(expectedUser, nil)
res, err := _userUseCase.UpdateUser(user)

// THEN
assert.Nil(t, err)
assert.Equal(t, expectedUser.ID, res.ID)
assert.Equal(t, expectedUser.Name, res.Name)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Update_User_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
user := domain.User{ID: 1, Name: "updated-user", Age: 18}
errStr := fmt.Sprintf("Unexpected Error")
expectedErr := domain.NewUnexpectedError(errStr)

// WHEN
_userMockRepo.EXPECT().UpdateUser(gomock.Any()).Return(domain.User{}, expectedErr)
_, err := _userUseCase.UpdateUser(user)

// THEN
assert.NotNil(t, err)
assert.Equal(t, expectedErr.Message, err.Message)
}

func Test_Should_Delete_User_By_Id_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
var id uint = 1

// WHEN
_userMockRepo.EXPECT().DeleteUserById(gomock.Any()).Return(nil)
err := _userUseCase.DeleteUserById(id)

// THEN
assert.Nil(t, err)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Delete_User_By_Id_With_MockUserRepository(t *testing.T) {
mockUseCaseSetup(t)

// GIVEN
var id uint = 1
errStr := fmt.Sprintf("Unexpected Error")
expectedErr := domain.NewUnexpectedError(errStr)

// WHEN
_userMockRepo.EXPECT().DeleteUserById(gomock.Any()).Return(expectedErr)
err := _userUseCase.DeleteUserById(id)

// THEN
assert.NotNil(t, err)
assert.Equal(t, expectedErr.Message, err.Message)
}

main_test.go

package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"go-app/config"
"go-app/domain"
"go-app/mocks"
"go-app/user"
"net/http"
"net/http/httptest"
"testing"
)

var (
_userMockUseCase *mocks.MockUserUseCase
_userHandler *user.Handler
)

func handlerSetupRouter(t *testing.T) *gin.Engine {
c := gomock.NewController(t)
defer c.Finish()

// Mock UserUseCase
_userMockUseCase = mocks.NewMockUserUseCase(c)

logger := config.ZapTestConfig()
_userHandler = user.NewUserHandler(_userMockUseCase, logger)

r := setupRouter(config.NewRelicConfig(), _userHandler)
return r

}

func Test_Should_Create_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
u := domain.User{Name: "created-user", Age: 22}
byteUser, _ := json.Marshal(u)
expectedUser := domain.User{ID: 10, Name: u.Name, Age: u.Age}

// WHEN
_userMockUseCase.EXPECT().CreateUser(gomock.Any()).Return(expectedUser, nil)

w := httptest.NewRecorder()
url := "/api/v1/users"
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(byteUser))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

// THEN
savedUser := domain.User{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &savedUser)

assert.Nil(t, err)
assert.Equal(t, 201, w.Code)
assert.Equal(t, expectedUser.Name, savedUser.Name)
assert.NotEmpty(t, savedUser.ID)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Create_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
u := domain.User{Name: "created-user", Age: 22}
byteUser, _ := json.Marshal(u)

gormErr := errors.New("Unexpected Error")
expectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
_userMockUseCase.EXPECT().CreateUser(gomock.Any()).Return(domain.User{}, expectedErr)

w := httptest.NewRecorder()
url := "/api/v1/users"
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(byteUser))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

// THEN
resErr := domain.AppError{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &resErr)

assert.Nil(t, err)
assert.Equal(t, 500, w.Code)
assert.Equal(t, expectedErr.Message, resErr.Message)
}

func Test_Should_Find_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
var id uint = 1
expectedUser := domain.User{ID: id, Name: "test", Age: 18}

// WHEN
_userMockUseCase.EXPECT().GetUserById(gomock.Any()).Return(expectedUser, nil)

w := httptest.NewRecorder()
url := fmt.Sprintf("/api/v1/users/%d", id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(w, req)

// THEN
u := domain.User{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &u)

assert.Nil(t, err)
assert.Equal(t, 200, w.Code)
assert.Equal(t, id, u.ID)
}

func Test_Should_Return_Not_Found_Err_When_Invoke_Find_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
var id uint = 1
errStr := fmt.Sprintf("User not found, ID: %d", id)
expectedErr := domain.NewNotFoundError(errStr)

// WHEN
_userMockUseCase.EXPECT().GetUserById(gomock.Any()).Return(domain.User{}, expectedErr)

w := httptest.NewRecorder()
url := fmt.Sprintf("/api/v1/users/%d", id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(w, req)

// THEN
resErr := domain.AppError{}

assert.NotEmpty(t, w.Body.String())
_ = json.Unmarshal([]byte(w.Body.String()), &resErr)

assert.NotNil(t, resErr)
assert.Equal(t, 404, w.Code)
assert.Equal(t, expectedErr.Message, resErr.Message)
}

func Test_Should_Update_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
expectedUser := domain.User{ID: 5, Name: "updated-user", Age: 22}
byteUser, _ := json.Marshal(expectedUser)

// WHEN
_userMockUseCase.EXPECT().UpdateUser(gomock.Any()).Return(expectedUser, nil)

w := httptest.NewRecorder()
url := "/api/v1/users"
req, _ := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(byteUser))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

// THEN
updatedUser := domain.User{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &updatedUser)

assert.Nil(t, err)
assert.Equal(t, 200, w.Code)
assert.Equal(t, expectedUser.Name, updatedUser.Name)
assert.NotEmpty(t, updatedUser.ID)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Update_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
expectedUser := domain.User{ID: 5, Name: "updated-user", Age: 22}
byteUser, _ := json.Marshal(expectedUser)

gormErr := errors.New("Unexpected Error")
expectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
_userMockUseCase.EXPECT().UpdateUser(gomock.Any()).Return(domain.User{}, expectedErr)

w := httptest.NewRecorder()
url := "/api/v1/users"
req, _ := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(byteUser))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

// THEN
resErr := domain.AppError{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &resErr)

assert.Nil(t, err)
assert.Equal(t, 500, w.Code)
assert.Equal(t, expectedErr.Message, resErr.Message)
}

func Test_Should_Delete_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
var id uint = 1

// WHEN
_userMockUseCase.EXPECT().DeleteUserById(gomock.Any()).Return(nil)

w := httptest.NewRecorder()
url := fmt.Sprintf("/api/v1/users/%d", id)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
router.ServeHTTP(w, req)

// THEN
assert.Equal(t, 204, w.Code)
}

func Test_Should_Return_Unexpected_Err_When_Invoke_Delete_User_With_MockUserUseCase(t *testing.T) {
router := handlerSetupRouter(t)

// GIVEN
var id uint = 1
gormErr := errors.New("Unexpected Error")
expectedErr := domain.NewUnexpectedError(gormErr.Error())

// WHEN
_userMockUseCase.EXPECT().DeleteUserById(gomock.Any()).Return(expectedErr)

w := httptest.NewRecorder()
url := fmt.Sprintf("/api/v1/users/%d", id)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
router.ServeHTTP(w, req)

// THEN
resErr := domain.AppError{}

assert.NotEmpty(t, w.Body.String())
err := json.Unmarshal([]byte(w.Body.String()), &resErr)

assert.Nil(t, err)
assert.Equal(t, 500, w.Code)
assert.Equal(t, expectedErr.Message, resErr.Message)
}

Run Tests

Run tests with IDE or the below command.

go test -v ./…

All tests passed.

If you want to see test coverage, you can run these commands.

go test -covermode=count -coverpkg=./... -coverprofile coverage.out -v ./...
go tool cover -html coverage.out

In this way, it generates the “coverage.out” file in the root path and opens the “coverage.out” file in your browser, and you can see test coverage.

Send HTTP Request

Create User

Get User

Failed HTTP Request

We will try to save the user without a name field, and we will take an error.

Let’s Take a Look at Sentry and New Relic

We see requestId in issue detail.
(d95e27e9–8691–402c-a6ac-b026d26b1b57)

When we search the log in New Relic, we find the log with the same request ID.

In this log message, we can see the status code, HTTP method type, HTTP request url, request body, and response body.

--

--

Mert ÇAKMAK

Software Developer at Getir. #SpringBoot #GoLang #Javascript #DevOps #DistributedSystems https://www.linkedin.com/in/mert-cakmak-474944125/