Цели

  • В промежуточном ПО с помощью идентификатора запросов отправить в New Relic журнал запросов и журнал ответов.
  • Отправить в Sentry с помощью идентификатора запросов ошибку, которой перехвачено исключение.
  • Имитация базы данных, репозитория, варианта применения.
  • Модульный тест.
  • Мониторинг службы с Prometheus и Grafana.
  • CRUD-операции.
Источник

Технологический стек:

  • Golang;
  • Prometheus;
  • Grafana;
  • New Relic;
  • Sentry.

Что такое New Relic?

New Relic  —  это платформа наблюдаемости для создания более совершенного ПО на основе полного понимания системы, эффективного анализа данных из любого цифрового источника и реагирования на инциденты, прежде чем они могли бы превратиться в проблемы. Задействование функционала платформы New Relic начинается с трехэтапной процедуры.

Что такое Sentry?

Sentry  —  это инструмент мониторинга программного обеспечения для выявления и устранения проблем, связанных с кодом. От отслеживания ошибок до мониторинга производительности  —  с Sentry обеспечивается наблюдаемость на уровне кода, благодаря чему упрощаются диагностирование проблем и непрерывное изучение работоспособности кода приложения.

Prometheus и Grafana

Не будем подробно останавливаться на Prometheus и Grafana.

Запускаем контейнеры Docker

Создадим файл docker-compose и запустим контейнеры.

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']

Запускаем такую команду:

docker-compose up -d

Создание приложения Go

  • Сначала командой go get установим пакеты:
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

Напишем код для приложения Go

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
db := config.ConnectPostgres()
database.Migrate(db)
defer func() {
dbInstance, _ := db.DB()
_ = dbInstance.Close()
}()

// Конфигурации для: Sentry, New Relic и Zap
config.SentryConfig()
newRelicConfig := config.NewRelicConfig()
logger = config.ZapConfig(newRelicConfig)

// Пользовательские: репозиторий, вариант применения и обработчик
userRepo := user.NewUserRepository(db)
userUseCase := user.NewUserUseCase(userRepo, logger)
userHandler := user.NewUserHandler(userUseCase, logger)

// Настройка маршрутизатора
router := setupRouter(newRelicConfig, userHandler)
router.Run(":8080")
}

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

// Промежуточные ПО
_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 опрашивается эта конечная точка.

Пакет конфигурации

Создадим в пакете конфигурационные файлы Sentry, New Relic, Zap и Postgres.

env_config.go

go-envconfig: с этим пакетом выполняется парсинг переменных окружения в структуру Go, упрощается считывание конфигураций из окружения.

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: драйвер PostgreSQL для взаимодействия с базами данных PostgreSQL.
  • gorm.io/gorm: библиотека объектно-реляционного отображения для Go.
  • once: этим экземпляром sync.Once обеспечивается, что блок кода, которым инициализируется подключение к базе данных, выполняется только раз независимо от того, сколько раз вызывается ConnectPostgres.

Пользовательский пакет

Создадим в пакете файлы use_case.go, repository.go, handler.go и тестовые файлы.

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: цель здесь  —  автоматически генерировать имитированную версию интерфейса UserRepository, эта имитация помещается в каталог ../mocks с названием файла 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: цель здесь  —  автоматически генерировать имитированную версию интерфейса UserUseCase, эта имитация помещается в каталог ../mocks с названием файла mockUserUsecase.go.

Запускаем такую команду:

go generate ./…

Ею генерируются файлы заглушки.

handler.go

Создадим HTTP-сервер с помощью 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(): если возвращается ошибка, исключение перехватывается и отправляется в Sentry.

Пакет промежуточного ПО

Создадим в пакете файл middleware.go.

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(): промежуточное ПО для New Relic.

SentryMiddleware(): промежуточное ПО для Sentry.

LogMiddleware(): этим промежуточным ПО в New Relic отправляются журналы HTTP-запросов и HTTP-ответов, генерируется идентификатор запросов, который задается в виде тега, и так устанавливается связь между журналом New Relic и ошибкой Sentry.

Напишем модульный тест

go-sql-mock: sqlmock  —  это имитационная библиотека для реализации sql/драйвера, единственная ее цель  —  моделировать любое поведение драйвера SQL в тестах без реального подключения к базе данных, с помощью sqlmock поддерживается корректный рабочий процесс разработки через тестирование.

stretchr/testify: инструментарий с наиболее распространенными ассертами и мок-объектами, неплохо сочетается со стандартной библиотекой.

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)

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

// Когда
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)

// То
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)

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

// Когда
mock.ExpectBegin()
mock.ExpectQuery(`INSERT INTO "users"`).
WithArgs(user.Name, user.Age, user.CreatedDate).
WillReturnError(gormErr)
mock.ExpectCommit()

_, err := repo.CreateUser(user)

// То
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)

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

// Когда
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)

// То
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)

// Дано
var id uint = 1
expectedError := domain.NewNotFoundError("User not found, ID: 1")

// Когда
expectedSQL := "SELECT (.+) FROM \"users\" WHERE id =(.+)"
mock.ExpectQuery(expectedSQL).WillReturnError(gorm.ErrRecordNotFound)

_, err := repo.GetUserById(id)

// То
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)

// Дано
var id uint = 1
expectedError := domain.NewUnexpectedError("Unexpected Err")

// Когда
expectedSQL := "SELECT (.+) FROM \"users\" WHERE id =(.+)"
mock.ExpectQuery(expectedSQL).WillReturnError(gorm.ErrNotImplemented)

_, err := repo.GetUserById(id)

// То
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)

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

// Когда
updUserSQL := "UPDATE \"users\" SET .+"
mock.ExpectBegin()
mock.ExpectExec(updUserSQL).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

updateUser, err := repo.UpdateUser(user)

// То
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)

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

// Когда
mock.ExpectBegin()
mock.ExpectExec("UPDATE \"users\" SET .+").
WillReturnError(gormErr)
mock.ExpectCommit()

_, err := repo.UpdateUser(user)

// То
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)

// Дано
user := domain.User{ID: 1}

// Когда
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM \"users\" WHERE (.+)$").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()

err := repo.DeleteUserById(user.ID)

// То
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)

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

// Когда
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM \"users\" WHERE (.+)$").
WillReturnError(gormErr)
mock.ExpectCommit()

err := repo.DeleteUserById(user.ID)

// То
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()

// Имитируем «UserRepository»
_userMockRepo = mocks.NewMockUserRepository(c)

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

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

// Дано
user := domain.User{Name: "test", Age: 18}
expectedUser := domain.User{ID: 1, Name: "test", Age: 18}

// Когда
_userMockRepo.EXPECT().CreateUser(gomock.Any()).Return(expectedUser, nil)
res, err := _userUseCase.CreateUser(user)

// То
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)

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

// Когда
_, err := _userUseCase.CreateUser(user)

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

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

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

// Когда
_userMockRepo.EXPECT().CreateUser(gomock.Any()).Return(domain.User{}, expectedErr)
_, err := _userUseCase.CreateUser(user)

// То
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)

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

// Когда
_userMockRepo.EXPECT().GetUserById(id).Return(expectedUser, nil)
res, err := _userUseCase.GetUserById(id)

// То
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)

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

// Когда
_userMockRepo.EXPECT().GetUserById(gomock.Any()).Return(domain.User{}, notFoundErr)
_, err := _userUseCase.GetUserById(id)

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

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

// Дано
user := domain.User{ID: 1, Name: "updated-user", Age: 18}
expectedUser := domain.User{ID: 1, Name: "updated-user", Age: 18}

// Когда
_userMockRepo.EXPECT().UpdateUser(gomock.Any()).Return(expectedUser, nil)
res, err := _userUseCase.UpdateUser(user)

// То
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)

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

// Когда
_userMockRepo.EXPECT().UpdateUser(gomock.Any()).Return(domain.User{}, expectedErr)
_, err := _userUseCase.UpdateUser(user)

// То
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)

// Дано
var id uint = 1

// Когда
_userMockRepo.EXPECT().DeleteUserById(gomock.Any()).Return(nil)
err := _userUseCase.DeleteUserById(id)

// То
assert.Nil(t, err)
}

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

// Дано
var id uint = 1
errStr := fmt.Sprintf("Unexpected Error")
expectedErr := domain.NewUnexpectedError(errStr)

// Когда
_userMockRepo.EXPECT().DeleteUserById(gomock.Any()).Return(expectedErr)
err := _userUseCase.DeleteUserById(id)

// То
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()

// Имитируем «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)

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

// Когда
_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)

// То
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)

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

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

// Когда
_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)

// То
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)

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

// Когда
_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)

// То
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)

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

// Когда
_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)

// То
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)

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

// Когда
_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)

// То
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)

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

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

// Когда
_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)

// То
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)

// Дано
var id uint = 1

// Когда
_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)

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

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

// Дано
var id uint = 1
gormErr := errors.New("Unexpected Error")
expectedErr := domain.NewUnexpectedError(gormErr.Error())

// Когда
_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)

// То
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)
}

Запускаем тесты

Запустим тесты в интегрированной среде разработки или такой командой:

go test -v ./…

Все тесты пройдены.

Чтобы посмотреть тестовое покрытие, запускаем такие команды:

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

Файл coverage.out с тестовым покрытием генерируется в корневом пути и открывается в браузере.

Отправка HTTP-запроса

Создаем пользователя

Получаем пользователя

HTTP-запрос не выполнился

Сохраняя пользователя без поля name, получаем ошибку.

Посмотрим на Sentry и New Relic

В сведениях об ошибке видим requestId: d95e27e9–8691–402c-a6ac-b026d26b1b57.

В New Relic находим лог с тем же request ID.

А в этом сообщении журнала  —  код состояния, метод HTTP, url-адрес HTTP-запроса, тело запроса и тело ответа.

Prometheus и Grafana

Дашборд Grafana на Go.

ID: 10826

Посмотрим метрики в дашборде Grafana.

Репозиторий на GitHub.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Mert ÇAKMAK: Monitoring the Golang App with Prometheus, Grafana, New Relic and Sentry

Предыдущая статьяПрактическое предметно-ориентированное проектирование