Цели
- В промежуточном ПО с помощью идентификатора запросов отправить в 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.
Читайте также:
- Типы функций и функции высшего порядка на Go
- Продвинутый поиск Meilisearch для приложения Golang
- Автоматизация платежей со Stripe и Golang: руководство разработчика
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mert ÇAKMAK: Monitoring the Golang App with Prometheus, Grafana, New Relic and Sentry