Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель — разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.

Дисклеймер
Я только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти «удобные вещи» также присутствуют в Go, просто я их не нашел.
Также, отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в «чистой» архитектуре. Да и тестировать так проще.
Хватит прелюдий, вперед в бой!
Итоговый результат
Анимация работы будущего бота

Для тех, кому читать код интересней, чем статью — прошу сюда.
Структура приложения
Разобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit (ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте:
config/ storage/ ui/ web/ -- clients/ -- server/ main.go
Сервер
Для сервера будем использовать стандартный сервер из пакета http. Создадим структуру Server следующего вида в web -> server:
server.go
package server import ( "context" "log" "net/http" "os" "os/signal" "sync/atomic" "time" ) type Server struct { // Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.go healthy int32 logger *log.Logger } func NewServer(logger *log.Logger) *Server { return &Server{ logger: logger, } }
Эта структура будет выступать хранилищем зависимостей для наших хэндлеров. Есть несколько подходов для организации работы с хэндлерами и их зависимостями. Например, можно объявлять и запускать все в main.go, там же где мы создаем экземпляры наших структур и интерфейсов. Но это плохой путь. Еще есть вариант использовать глобальные переменные и просто их импортировать. Но в таком случае становится сложно покрывать проект тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам нужно запустить наш сервер. Напишем метод:
server.go
func (s *Server) setupRouter() http.Handler { // TODO router := http.NewServeMux() return router } func (s *Server) Serve(address string) { server := &http.Server{ Addr: address, Handler: s.setupRouter(), ErrorLog: s.logger, // Наш логгер ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 15 * time.Second, } // Создаем каналы для корректного завершения процесса done := make(chan bool) quit := make(chan os.Signal, 1) // Настраиваем сигнал для корректного завершения процесса signal.Notify(quit, os.Interrupt) go func() { <-quit s.logger.Println("Server is shutting down...") // Эта переменная пригодится для healthcheck'а например atomic.StoreInt32(&s.healthy, 0) // Даем клиентам 30 секунд для завершения всех операций, прежде чем сервер будет остановлен ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Информируем сервер о том, что не нужно держать существующие коннекты server.SetKeepAlivesEnabled(false) // Выключаем сервер if err := server.Shutdown(ctx); err != nil { s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err) } close(done) }() s.logger.Println("Server is ready to handle requests at", address) // Переменная для проверки того, что сервер запустился и все хорошо atomic.StoreInt32(&s.healthy, 1) // Запускаем сервер if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Fatalf("Could not listen on %s: %v\n", address, err) } // Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат <-done s.logger.Println("Server stopped") }
Теперь давайте создадим первый хэндлер. Создадим папку в web -> server -> handlers:
healthcheck.go
package handlers import ( "net/http" ) func Healthcheck() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write("OK") }) }
Добавим наш хэндлер в роутер:
server.go
// Наш код выше func (s *Server) setupRouter() http.Handler { router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) return router } // Наш код ниже
Идем в main.go и пробуем запустить наш сервер:
package main import ( "log" "os" "go-scrum-poker-bot/web/server" ) func main() { // Создаем логгер со стандартными флагами и префиксом "INFO:". // Писать он будет только в stdout logger := log.New(os.Stdout, "INFO: ", log.LstdFlags) app := server.NewServer(logger) app.Serve(":8000") }
Пробуем запустить проект:
go run main.go
Если все хорошо, то сервер запустится на :8000 порту. Наш текущий подход к созданию хэндлеров позволяет передавать в них любые зависимости. Это нам еще пригодится, когда мы будем писать тесты. 😉 Прежде чем идти дальше, нам нужно немного настроить нашу локальную среду, чтобы Slack смог с нами взаимодействовать.
NGROK
Для того, чтобы можно было локально проверять работу нашего бота, нам нужно установить себе туннель ngrok. Вообще можно любой другой, но этот вариант удобный и прост в использовании. Да и Slack его советует. В общем, когда все будет готово, запустите его командой:
ngrok http 8000
Если все хорошо, то вы увидите что-то вроде этого:
ngrok by @inconshreveable (Ctrl+C to quit) Session Status online Account Sayakhov Ilya (Plan: Free) Version 2.3.35 Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding http://ffd3cfcc460c.ngrok.io -> http://localhost:8000 Forwarding https://ffd3cfcc460c.ngrok.io -> http://localhost:8000 Connections ttl opn rt1 rt5 p50 p90 0 0 0.00 0.00 0.00 0.00
Нас интересует строчка https://ffd3cfcc460c.ngrok.io. Она нам понадобится дальше.
Slash commands
Создадим наше приложение в Slack. Для этого нужно перейти сюда -> Create New App. Далее указываем имя GoScrumPokerBot и добавляем его в свой Workspace. Далее, нам нужно дать нашему боту права. Для этого идем в OAuth & Permissions -> Scopes и добавляем следующие права: chat:write, commands. Первый набор прав нужен, чтобы бот мог писать в каналы, а второй для slash команд. И наконец нажимаем на Reinstall to Workspace. Готово! Теперь идем в раздел Slash commands и добавляем нашу команду /poker .
В Request URL нужно вписать адрес из пункта выше + путь. Пусть будет так: https://ffd3cfcc460c.ngrok.io/play-poker.
Slash command handler
Теперь создадим хэндлер для обработки событий на только созданную команду. Идем в web -> server -> handlers и создаем файл play_poker.go:
func PlayPokerCommand() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`)) }) }
Добавляем наш хэндлер в роутер:
server.go
func (s *Server) setupRouter() http.Handler { router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) router.Handle( "/play-poker", handlers.PlayPokerCommand(), ) return router }
Идем в Slack и пробуем выполнить эту команду: /poker. В ответ вы должны получить что-то вроде этого:

Но это не единственный вариант взаимодействия со Slack. Мы также можем слать сообщения в канал. Этот вариант мне понравился больше и плюс у него больше возможностей в сравнении с ответом на команду. Например вы можете послать сообщение в фоне (если оно требует долгих вычислений). Давайте напишем наш http клиента. Идем в web -> clients. Создаем файл client.go:
client.go
package clients // Создадим новый тип для наших хэндлеров type Handler func(request *Request) *Response // Создадим новый тип для middleware (о них чуть позже) type Middleware func(handler Handler, request *Request) Handler // Создадим интерфейс http клиента type Client interface { Make(request *Request) *Response } // Наша реализация клиента type BasicClient struct { client *http.Client middleware []Middleware } func NewBasicClient(client *http.Client, middleware []Middleware) Client { return &BasicClient{client: client, middleware: middleware} } // Приватный метод для всей грязной работы func (c *BasicClient) makeRequest(request *Request) *Response { payload, err := request.ToBytes() // TODO if err != nil { return &Response{Error: err} } // Создаем новый request, передаем в него данные req, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload)) if err != nil { return &Response{Error: err} } // Применяем заголовки for name, value := range request.Headers { req.Header.Add(name, value) } // Выполняем запрос resp, err := c.client.Do(req) if err != nil { return &Response{Error: err} } defer resp.Body.Close() // Читаем тело ответа body, err := ioutil.ReadAll(resp.Body) if err != nil { return &Response{Error: err} } err = nil // Если вернулось что-то отличное выше или ниже 20x, то ошибка if resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK { err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body)) } return &Response{ Status: resp.StatusCode, Body: body, Headers: resp.Header, Error: err, } } // Наш публичный метод для запросов func (c *BasicClient) Make(request *Request) *Response { if request.Headers == nil { request.Headers = make(map[string]string) } // Применяем middleware handler := c.makeRequest for _, middleware := range c.middleware { handler = middleware(handler, request) } return handler(request) }
Теперь создадим файл web -> clients:
request.go
package clients import "encoding/json" type Request struct { URL string Method string Headers map[string]string Json interface{} } func (r *Request) ToBytes() ([]byte, error) { if r.Json != nil { result, err := json.Marshal(r.Json) if err != nil { return []byte{}, err } return result, nil } return []byte{}, nil }
Сразу напишем тесты к методу ToBytes(). Для тестов я взял testify/assert, так как без нее была бы куча if’ов, а меня они напрягают 🙂 . К тому же, я привык к pytest и его assert, да и как-то глазу приятнее:
request_test.go
package clients_test import ( "encoding/json" "go-scrum-poker-bot/web/clients" "reflect" "testing" "github.com/stretchr/testify/assert" ) func TestRequestToBytes(t *testing.T) { // Здесь мы делаем что-то вроде pytest.parametrize (жаль, что в Go нет сахара для декораторов, это было бы удобнее) testCases := []struct { json interface{} data []byte err error }{ {map[string]string{"test_key": "test_value"}, []byte("{\"test_key\":\"test_value\"}"), nil}, {nil, []byte{}, nil}, {make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}}, } // Проходимся по нашим тест кейсам for _, testCase := range testCases { request := clients.Request{ URL: "https://example.com", Method: "GET", Headers: nil, Json: testCase.json, } actual, err := request.ToBytes() // Проверяем результаты assert.Equal(t, testCase.err, err) assert.Equal(t, testCase.data, actual) } }
И нам нужен web -> clients:
response.go
package clients import "encoding/json" type Response struct { Status int Headers map[string][]string Body []byte Error error } // Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nil func (r *Response) Json(to interface{}) error { if r.Error != nil { return r.Error } return json.Unmarshal(r.Body, to) }
И также, напишем тесты для метода Json(to interface{}):
response_test.go
package clients_test import ( "errors" "go-scrum-poker-bot/web/clients" "testing" "github.com/stretchr/testify/assert" ) // Один тест на позитивный кейс func TestResponseJson(t *testing.T) { to := struct { TestKey string `json:"test_key"` }{} response := clients.Response{ Status: 200, Headers: nil, Body: []byte(`{"test_key": "test_value"}`), Error: nil, } err := response.Json(&to) assert.Equal(t, nil, err) assert.Equal(t, "test_value", to.TestKey) } // Один тест на ошибку func TestResponseJsonError(t *testing.T) { expectedErr := errors.New("Error!") response := clients.Response{ Status: 200, Headers: nil, Body: nil, Error: expectedErr, } err := response.Json(map[string]string{}) assert.Equal(t, expectedErr, err) }
Теперь, когда у нас есть все необходимое, нам нужно написать тесты для клиента. Есть несколько вариантов написания тестов для http клиента. Я выбрал вариант с подменой http транспорта. Однако есть и другие варианты, но этот мне показался удобнее:
client_test.go
package clients_test import ( "bytes" "go-scrum-poker-bot/web/clients" "io/ioutil" "net/http" "testing" "github.com/stretchr/testify/assert" ) // Для удобства объявим новый тип type RoundTripFunc func(request *http.Request) *http.Response func (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request), nil } // Создание mock тестового клиента func NewTestClient(fn RoundTripFunc) *http.Client { return &http.Client{ Transport: RoundTripFunc(fn), } } // Валидный тест func TestMakeRequest(t *testing.T) { url := "https://example.com/ok" // Создаем mock клиента и пишем нужный нам ответ httpClient := NewTestClient(func(req *http.Request) *http.Response { assert.Equal(t, req.URL.String(), url) return &http.Response{ StatusCode: http.StatusOK, Body: ioutil.NopCloser(bytes.NewBufferString("OK")), Header: make(http.Header), } }) // Создаем нашего http клиента с замоканным http клиентом webClient := clients.NewBasicClient(httpClient, nil) response := webClient.Make(&clients.Request{ URL: url, Method: "GET", Headers: map[string]string{"Content-Type": "application/json"}, Json: nil, }) assert.Equal(t, http.StatusOK, response.Status) } // Тест на ошибочный response func TestMakeRequestError(t *testing.T) { url := "https://example.com/error" httpClient := NewTestClient(func(req *http.Request) *http.Response { assert.Equal(t, req.URL.String(), url) return &http.Response{ StatusCode: http.StatusBadGateway, Body: ioutil.NopCloser(bytes.NewBufferString("Bad gateway")), Header: make(http.Header), } }) webClient := clients.NewBasicClient(httpClient, nil) response := webClient.Make(&clients.Request{ URL: url, Method: "GET", Headers: map[string]string{"Content-Type": "application/json"}, Json: nil, }) assert.Equal(t, http.StatusBadGateway, response.Status) }
Отлично! Теперь давайте напишем middleware. Я привык для каждой, даже самой маленькой задачи, писать отдельную маленькую middleware. Так можно легко переиспользовать такой код в разных проектах / для разных API с разными требованиями к заголовкам / авторизации и так далее. Slack требует при отправке сообщений в канал указывать Authorization заголовок с токеном, который вы сможете найти в разделе OAuth & Permissions. Создаем в web -> clients -> middleware:
auth.go
package middleware import ( "fmt" "go-scrum-poker-bot/web/clients" ) // Токен будем передавать при определении middleware на этапе инициализации клиента func Auth(token string) clients.Middleware { return func(handler clients.Handler, request *clients.Request) clients.Handler { return func(request *clients.Request) *clients.Response { request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token) return handler(request) } } }
И напишем тест к ней:
auth_test.go
package middleware_test import ( "fmt" "go-scrum-poker-bot/web/clients" "go-scrum-poker-bot/web/clients/middleware" "testing" "github.com/stretchr/testify/assert" ) func TestAuthMiddleware(t *testing.T) { token := "test" request := &clients.Request{ Headers: map[string]string{}, } handler := middleware.Auth(token)( func(request *clients.Request) *clients.Response { return &clients.Response{} }, request, ) handler(request) assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers) }
Также в репозитории вы сможете найти middleware для логирования и установки Content-Type: application/json. Здесь я не буду приводить этот код в целях экономии времени и места :).
Давайте перепишем наш PlayPoker хэндлер:
play_poker.go
package handlers import ( "errors" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/clients" "go-scrum-poker-bot/web/server/models" "net/http" "github.com/google/uuid" ) func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Добавим проверку, что нам пришли данные из POST Form с текстом и ID канала if r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" { w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODO return } resp := webClient.Make(&clients.Request{ URL: "https://slack.com/api/chat.postMessage", Method: "POST", Json: uiBuilder.Build( // TODO: Напишем builder позже r.PostFormValue("channel_id"), uuid.New().String(), r.PostFormValue("text"), nil, false, ), }) if resp.Error != nil { w.Write(models.ResponseError(resp.Error)) // TODO return } }) }
И создадим в web -> server -> models . Файл errors.go для быстрого формирования ошибок:
errors.go
package models import ( "encoding/json" "fmt" ) type SlackError struct { ResponseType string `json:"response_type"` Text string `json:"text"` } func ResponseError(err error) []byte { resp, err := json.Marshal( SlackError{ ResponseType: "ephemeral", Text: fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()), }, ) if err != nil { return []byte("Sorry. Some error happened") } return resp }
Напишем тесты для хэндлера:
play_poker_test.go
package handlers_test import ( "errors" "go-scrum-poker-bot/config" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/server/handlers" "go-scrum-poker-bot/web/server/models" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestPlayPokerHandler(t *testing.T) { config := config.NewConfig() // TODO mockClient := &MockClient{} uiBuilder := ui.NewBuilder(config) // TODO responseRec := httptest.NewRecorder() router := http.NewServeMux() router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder)) payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode() request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.ServeHTTP(responseRec, request) assert.Nil(t, err) assert.Equal(t, http.StatusOK, responseRec.Code) assert.Empty(t, responseRec.Body.String()) assert.Equal(t, true, mockClient.Called) } func TestPlayPokerHandlerEmptyBodyError(t *testing.T) { config := config.NewConfig() mockClient := &MockClient{} uiBuilder := ui.NewBuilder(config) responseRec := httptest.NewRecorder() router := http.NewServeMux() router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder)) payload := url.Values{}.Encode() request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.ServeHTTP(responseRec, request) expected := string(models.ResponseError(errors.New("Please write correct subject"))) assert.Equal(t, http.StatusOK, responseRec.Code) assert.Equal(t, expected, responseRec.Body.String()) assert.Equal(t, false, mockClient.Called) } func TestPlayPokerHandlerRequestError(t *testing.T) { errMsg := "Error msg" config := config.NewConfig() // TODO mockClient := &MockClient{Error: errMsg} uiBuilder := ui.NewBuilder(config) // TODO responseRec := httptest.NewRecorder() router := http.NewServeMux() router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder)) payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode() request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.ServeHTTP(responseRec, request) expected := string(models.ResponseError(errors.New(errMsg))) assert.Equal(t, http.StatusOK, responseRec.Code) assert.Equal(t, expected, responseRec.Body.String()) assert.Equal(t, true, mockClient.Called) }
Теперь нам нужно написать mock для нашего http клиента:
common_test.go
package handlers_test import ( "errors" "go-scrum-poker-bot/web/clients" ) type MockClient struct { Called bool Error string } func (c *MockClient) Make(request *clients.Request) *clients.Response { c.Called = true var err error = nil if c.Error != "" { err = errors.New(c.Error) } return &clients.Response{Error: err} }
Как видите, код хэндлера PlayPoker аккуратный и его просто покрывать тестами и не страшно в случае чего изменять.
Теперь можно приступить к написанию UI строителя интерфейсов для Slack UI Block Kit. Там все довольно просто, но много однотипного кода. Отмечу лишь, что Slack API мне не очень понравился и было тяжело с ним работать. Сам UI Builder можно глянуть в папке ui здесь. А здесь, в целях экономии времени, я не буду на нем заострять внимания. Отмечу лишь, что в качестве якоря для понимания того, событие от какого сообщения пришло и какой был текст для голосования (его мы не будем сохранять у себя, а будем брать непосредственно из события) будем использовать block_id. А для определения типа события будем смотреть на action_id.
Давайте создадим конфиг для нашего приложения. Идем в config и создаем:
config.go
package config type Config struct { App *App Slack *Slack Redis *Redis } func NewConfig() *Config { return &Config{ App: &App{ ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"), PokerRanks: getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"), }, Slack: &Slack{ Token: getStrEnv("SLACK_TOKEN", "FILL_ME"), }, // Скоро понадобится Redis: &Redis{ Host: getStrEnv("REDIS_HOST", "0.0.0.0"), Port: getIntEnv("REDIS_PORT", "6379"), DB: getIntEnv("REDIS_DB", "0"), }, } } // Получаем значение из env или выставляем default func getStrEnv(key string, defaultValue string) string { if value, ok := os.LookupEnv(key); ok { return value } return defaultValue } // Получаем int значение из env или выставляем default func getIntEnv(key string, defaultValue string) int { value, err := strconv.Atoi(getStrEnv(key, defaultValue)) if err != nil { panic(fmt.Sprintf("Incorrect env value for %s", key)) } return value } // Получаем список (e.g. 0,1,2,3,4,5) из env или выставляем default func getListStrEnv(key string, defaultValue string) []string { value := []string{} for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") { value = append(value, strings.TrimSpace(item)) } return value }
И напишем тесты к нему. Будем тестировать только публичные методы:
config_test.go
package config_test import ( "go-scrum-poker-bot/config" "os" "testing" "github.com/stretchr/testify/assert" ) func TestNewConfig(t *testing.T) { c := config.NewConfig() assert.Equal(t, "0.0.0.0", c.Redis.Host) assert.Equal(t, 6379, c.Redis.Port) assert.Equal(t, 0, c.Redis.DB) assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks) } func TestNewConfigIncorrectIntFromEnv(t *testing.T) { os.Setenv("REDIS_PORT", "-") assert.Panics(t, func() { config.NewConfig() }) }
Я намеренно сделал обязательность выставления значений по умолчанию, хотя это не самый правильный путь. Изменим main.go:
main.go
package main import ( "fmt" "go-scrum-poker-bot/config" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/clients" clients_middleware "go-scrum-poker-bot/web/clients/middleware" "go-scrum-poker-bot/web/server" "log" "net/http" "os" "time" ) func main() { logger := log.New(os.Stdout, "INFO: ", log.LstdFlags) config := config.NewConfig() builder := ui.NewBuilder(config) webClient := clients.NewBasicClient( &http.Client{ Timeout: 5 * time.Second, }, []clients.Middleware{ // Наши middleware clients_middleware.Auth(config.Slack.Token), clients_middleware.JsonContentType, clients_middleware.Log(logger), }, ) app := server.NewServer( logger, webClient, builder, ) app.Serve(config.App.ServerAddress) }
Теперь при запуске команды /poker мы в ответ получим наш симпатичный минималистичный интерфейс.
Slack Interactivity
Давайте научимся реагировать на события при взаимодействии пользователя с ним. Зайдем Your apps -> Наш бот -> Interactivity & Shortcuts. В Request URL введем:
https://ffd3cfcc460c.ngrok.io/interactivity
Создадим еще один хэндлер InteractionCallback в web -> server -> handlers:
interaction_callback.go
package handlers import ( "go-scrum-poker-bot/storage" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/ui/blocks" "go-scrum-poker-bot/web/clients" "go-scrum-poker-bot/web/server/models" "net/http" ) func InteractionCallback( userStorage storage.UserStorage, sessionStorage storage.SessionStorage, uiBuilder *ui.Builder, webClient clients.Client, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var callback models.Callback // Об этом ниже data, err := callback.SerializedData([]byte(r.PostFormValue("payload"))) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // TODO: Скоро доберемся до них users := userStorage.All(data.SessionID) visible := sessionStorage.GetVisibility(data.SessionID) err = nil // Определяем какое событие к нам поступило и реализуем немного логики исходя из него switch data.Action.ActionID { case ui.VOTE_ACTION_ID: users[callback.User.Username] = data.Action.SelectedOption.Value err = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value) case ui.RESULTS_VISIBILITY_ACTION_ID: visible = !visible err = sessionStorage.SetVisibility(data.SessionID, visible) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Шлем ответ перерисовывая интерфейс сообщения через response URL. Для пользователя все пройдет незаметно resp := webClient.Make(&clients.Request{ URL: callback.ResponseURL, Method: "POST", Json: &blocks.Interactive{ ReplaceOriginal: true, Blocks: uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible), LinkNames: true, }, }) if resp.Error != nil { http.Error(w, resp.Error.Error(), http.StatusInternalServerError) return } }) }
Мы пока не определили наше хранилище. Давайте определим их интерфейсы и напишем тест на этот хэндлер. Идем в storage:
storage.go
package storage type UserStorage interface { All(sessionID string) map[string]string Save(sessionID string, username string, value string) error } type SessionStorage interface { GetVisibility(sessionID string) bool SetVisibility(sessionID string, state bool) error }
Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).
Теперь нужно создать модель Callback. Идем в web -> server -> models:
callback.go
package models import ( "encoding/json" "errors" "go-scrum-poker-bot/ui" ) type User struct { Username string `json:"username"` } type Text struct { Type string `json:"type"` Text string `json:"text"` } type Block struct { Type string `json:"type"` BlockID string `json:"block_id"` Text *Text `json:"text,omitempty"` } type Message struct { Blocks []*Block `json:"blocks,omitempty"` } type SelectedOption struct { Value string `json:"value"` } type Action struct { BlockID string `json:"block_id"` ActionID string `json:"action_id"` Value string `json:"value,omitempty"` SelectedOption *SelectedOption `json:"selected_option,omitempty"` } type SerializedData struct { SessionID string Subject string Action *Action } type Callback struct { ResponseURL string `json:"response_url"` User *User `json:"user"` Actions []*Action `json:"actions"` Message *Message `json:"message,omitempty"` } // Грязно достаем ID сессии, но другого способа я не смог придумать func (c *Callback) getSessionID() (string, error) { for _, action := range c.Actions { if action.BlockID != "" { return action.BlockID, nil } } return "", errors.New("Invalid session ID") } // Текст для голосования func (c *Callback) getSubject() (string, error) { for _, block := range c.Message.Blocks { if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil { return block.Text.Text, nil } } return "", errors.New("Invalid subject") } // Какое событие к нам пришло func (c *Callback) getAction() (*Action, error) { for _, action := range c.Actions { if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID { return action, nil } } return nil, errors.New("Invalid action") } func (c *Callback) SerializedData(data []byte) (*SerializedData, error) { err := json.Unmarshal(data, c) if err != nil { return nil, err } sessionID, err := c.getSessionID() if err != nil { return nil, err } subject, err := c.getSubject() if err != nil { return nil, err } action, err := c.getAction() if err != nil { return nil, err } return &SerializedData{ SessionID: sessionID, Subject: subject, Action: action, }, nil }
Давайте напишем тест на наш хэндлер:
interaction_callback_test.go
package handlers_test import ( "encoding/json" "go-scrum-poker-bot/config" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/server/handlers" "go-scrum-poker-bot/web/server/models" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestInteractionCallbackHandlerActions(t *testing.T) { config := config.NewConfig() mockClient := &MockClient{} mockUserStorage := &MockUserStorage{} mockSessionStorage := &MockSessionStorage{} uiBuilder := ui.NewBuilder(config) router := http.NewServeMux() router.Handle( "/interactivity", handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient), ) actions := []*models.Action{ { BlockID: "test", ActionID: ui.RESULTS_VISIBILITY_ACTION_ID, Value: "test", SelectedOption: nil, }, { BlockID: "test", ActionID: ui.VOTE_ACTION_ID, Value: "test", SelectedOption: &models.SelectedOption{Value: "1"}, }, } // Проверяем на двух разных типах событий for _, action := range actions { responseRec := httptest.NewRecorder() data, _ := json.Marshal(models.Callback{ ResponseURL: "test", User: &models.User{Username: "test"}, Actions: []*models.Action{action}, Message: &models.Message{ Blocks: []*models.Block{ { Type: "test", BlockID: ui.SUBJECT_BLOCK_ID, Text: &models.Text{Type: "test", Text: "test"}, }, }, }, }) payload := url.Values{"payload": {string(data)}}.Encode() request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload)) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") router.ServeHTTP(responseRec, request) assert.Nil(t, err) assert.Equal(t, http.StatusOK, responseRec.Code) assert.Empty(t, responseRec.Body.String()) assert.Equal(t, true, mockClient.Called) } }
Осталось определить mock для наших хранилищ. Обновим файл common_test.go:
common_test.go
// Существующий код type MockUserStorage struct{} func (s *MockUserStorage) All(sessionID string) map[string]string { return map[string]string{"user": "1"} } func (s *MockUserStorage) Save(sessionID string, username string, value string) error { return nil } type MockSessionStorage struct{} func (s *MockSessionStorage) GetVisibility(sessionID string) bool { return true } func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error { return nil }
Добавив в роутер новый хэндлер:
server.go
// Существующий код func (s *Server) setupRouter() http.Handler { router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) router.Handle( "/play-poker", handlers.PlayPokerCommand(s.webClient, s.uiBuilder), ) router.Handle( "/interactivity", handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient), ) return router } // Существующий код
Все хорошо, но наш сервер никак не уведомляет нас о том, что к нему поступил запрос + если мы где-то поймаем панику, то сервер может упасть. Давайте это исправим через middleware. Создаем папку web -> server -> middleware:
log.go
package middleware import ( "log" "net/http" ) func Log(logger *log.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { logger.Printf( "Handle request: [%s]: %s - %s - %s", r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), ) }() next.ServeHTTP(w, r) }) } }
И напишем для нее тест:
log_test.go
package middleware_test import ( "bytes" "go-scrum-poker-bot/web/server/middleware" "log" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/stretchr/testify/assert" ) type logHandler struct{} func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {} func TestLogMiddleware(t *testing.T) { var buf bytes.Buffer logger := log.New(os.Stdout, "INFO: ", log.LstdFlags) // Выставляем для логгера output наш буффер, чтобы все писалось в него logger.SetOutput(&buf) handler := &logHandler{} // Берем mock recorder из стандартной библиотеки Go responseRec := httptest.NewRecorder() router := http.NewServeMux() router.Handle("/test", middleware.Log(logger)(handler)) request, err := http.NewRequest("GET", "/test", strings.NewReader("")) router.ServeHTTP(responseRec, request) assert.Nil(t, err) assert.Equal(t, http.StatusOK, responseRec.Code) // Проверяем, что в буффер что-то пришло. Этого нам достаточно, чтобы понять, что middleware успешно отработала assert.NotEmpty(t, buf.String()) }
Остальные middleware можете найти здесь.
Ну и наконец слой хранения данных. Я решил взять Redis, так как это проще, да и не нужно для такого рода задач что-то большее, как мне кажется. Воспользуемся библиотекой go-redis и там же возьмем redismock для тестов.
Для начала научимся сохранять и получать всех пользователей переданной Scrum Poker сессии. Идем в storage:
users.go
package storage import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // Шаблоны ключей const SESSION_USERS_TPL = "SESSION:%s:USERS" const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE" type UserRedisStorage struct { redis *redis.Client context context.Context } func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage { return &UserRedisStorage{ redis: redisClient, context: context.Background(), } } func (s *UserRedisStorage) All(sessionID string) map[string]string { users := make(map[string]string) // Пользователей будем хранить в set, так как сортировка для нас не принципиальна. // Заодно избавимся от необходимости искать дубликаты for _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() { users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val() } return users } func (s *UserRedisStorage) Save(sessionID string, username string, value string) error { err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err() if err != nil { return err } // Голоса пользователей будем хранить в обычных ключах. // Я сделал вечное хранение, но это легко можно поменять, изменив -1 на нужное значение err = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err() if err != nil { return err } return nil }
Напишем тесты:
users_test.go
package storage_test import ( "errors" "fmt" "go-scrum-poker-bot/storage" "testing" "github.com/go-redis/redismock/v8" "github.com/stretchr/testify/assert" ) func TestAll(t *testing.T) { sessionID, username, value := "test", "user", "1" redisClient, mock := redismock.NewClientMock() usersStorage := storage.NewUserRedisStorage(redisClient) // Redis mock требует обязательного указания всех ожидаемых команд и результаты их выполнения mock.ExpectSMembers( fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID), ).SetVal([]string{username}) mock.ExpectGet( fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username), ).SetVal(value) assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID)) } func TestSave(t *testing.T) { sessionID, username, value := "test", "user", "1" redisClient, mock := redismock.NewClientMock() usersStorage := storage.NewUserRedisStorage(redisClient) mock.ExpectSAdd( fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID), username, ).SetVal(1) mock.ExpectSet( fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username), value, -1, ).SetVal(value) assert.Equal(t, nil, usersStorage.Save(sessionID, username, value)) } func TestSaveSAddErr(t *testing.T) { sessionID, username, value, err := "test", "user", "1", errors.New("ERROR") redisClient, mock := redismock.NewClientMock() usersStorage := storage.NewUserRedisStorage(redisClient) mock.ExpectSAdd( fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID), username, ).SetErr(err) assert.Equal(t, err, usersStorage.Save(sessionID, username, value)) } func TestSaveSetErr(t *testing.T) { sessionID, username, value, err := "test", "user", "1", errors.New("ERROR") redisClient, mock := redismock.NewClientMock() usersStorage := storage.NewUserRedisStorage(redisClient) mock.ExpectSAdd( fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID), username, ).SetVal(1) mock.ExpectSet( fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username), value, -1, ).SetErr(err) assert.Equal(t, err, usersStorage.Save(sessionID, username, value)) }
Теперь определим хранилище для «покерной» сессии. Пока там будет лежать статус видимости голосов:
sessions.go
package storage import ( "context" "fmt" "strconv" "github.com/go-redis/redis/v8" ) // Шаблон для ключей const SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN" type SessionRedisStorage struct { redis *redis.Client context context.Context } func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage { return &SessionRedisStorage{ redis: redisClient, context: context.Background(), } } func (s *SessionRedisStorage) GetVisibility(sessionID string) bool { value, _ := strconv.ParseBool( s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(), ) return value } func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error { return s.redis.Set( s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID), strconv.FormatBool(state), -1, ).Err() }
И сразу напишем тесты для только что созданных методов:
sessions_test.go
package storage_test import ( "errors" "fmt" "go-scrum-poker-bot/storage" "strconv" "testing" "github.com/go-redis/redismock/v8" "github.com/stretchr/testify/assert" ) func TestGetVisibility(t *testing.T) { sessionID, state := "test", true redisClient, mock := redismock.NewClientMock() mock.ExpectGet( fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID), ).SetVal(strconv.FormatBool(state)) sessionStorage := storage.NewSessionRedisStorage(redisClient) assert.Equal(t, state, sessionStorage.GetVisibility(sessionID)) } func TestSetVisibility(t *testing.T) { sessionID, state := "test", true redisClient, mock := redismock.NewClientMock() mock.ExpectSet( fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID), strconv.FormatBool(state), -1, ).SetVal("1") sessionStorage := storage.NewSessionRedisStorage(redisClient) assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state)) } func TestSetVisibilityErr(t *testing.T) { sessionID, state, err := "test", true, errors.New("ERROR") redisClient, mock := redismock.NewClientMock() mock.ExpectSet( fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID), strconv.FormatBool(state), -1, ).SetErr(err) sessionStorage := storage.NewSessionRedisStorage(redisClient) assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state)) }
Отлично! Осталось изменить main.go и server.go:
server.go
package server import ( "context" "go-scrum-poker-bot/storage" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/clients" "go-scrum-poker-bot/web/server/handlers" "log" "net/http" "os" "os/signal" "sync/atomic" "time" ) // Новый тип для middleware type Middleware func(next http.Handler) http.Handler // Все зависимости здесь type Server struct { healthy int32 middleware []Middleware logger *log.Logger webClient clients.Client uiBuilder *ui.Builder userStorage storage.UserStorage sessionStorage storage.SessionStorage } // Добавляем их при инициализации сервера func NewServer( logger *log.Logger, webClient clients.Client, uiBuilder *ui.Builder, userStorage storage.UserStorage, sessionStorage storage.SessionStorage, middleware []Middleware, ) *Server { return &Server{ logger: logger, webClient: webClient, uiBuilder: uiBuilder, userStorage: userStorage, sessionStorage: sessionStorage, middleware: middleware, } } func (s *Server) setupRouter() http.Handler { router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) router.Handle( "/play-poker", handlers.PlayPokerCommand(s.webClient, s.uiBuilder), ) router.Handle( "/interactivity", handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient), ) return router } func (s *Server) setupMiddleware(router http.Handler) http.Handler { handler := router for _, middleware := range s.middleware { handler = middleware(handler) } return handler } func (s *Server) Serve(address string) { server := &http.Server{ Addr: address, Handler: s.setupMiddleware(s.setupRouter()), ErrorLog: s.logger, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 15 * time.Second, } done := make(chan bool) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) go func() { <-quit s.logger.Println("Server is shutting down...") atomic.StoreInt32(&s.healthy, 0) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() server.SetKeepAlivesEnabled(false) if err := server.Shutdown(ctx); err != nil { s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err) } close(done) }() s.logger.Println("Server is ready to handle requests at", address) atomic.StoreInt32(&s.healthy, 1) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Fatalf("Could not listen on %s: %v\n", address, err) } <-done s.logger.Println("Server stopped") }
main.go
package main import ( "fmt" "go-scrum-poker-bot/config" "go-scrum-poker-bot/storage" "go-scrum-poker-bot/ui" "go-scrum-poker-bot/web/clients" clients_middleware "go-scrum-poker-bot/web/clients/middleware" "go-scrum-poker-bot/web/server" server_middleware "go-scrum-poker-bot/web/server/middleware" "log" "net/http" "os" "time" "github.com/go-redis/redis/v8" ) func main() { logger := log.New(os.Stdout, "INFO: ", log.LstdFlags) config := config.NewConfig() // Объявляем Redis клиент redisCLI := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port), DB: config.Redis.DB, }) // Наш users storage userStorage := storage.NewUserRedisStorage(redisCLI) // Наш sessions storage sessionStorage := storage.NewSessionRedisStorage(redisCLI) builder := ui.NewBuilder(config) webClient := clients.NewBasicClient( &http.Client{ Timeout: 5 * time.Second, }, []clients.Middleware{ clients_middleware.Auth(config.Slack.Token), clients_middleware.JsonContentType, clients_middleware.Log(logger), }, ) // В Server теперь есть middleware app := server.NewServer( logger, webClient, builder, userStorage, sessionStorage, []server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json}, ) app.Serve(config.App.ServerAddress) }
Запустим тесты:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic
Результат:
go tool cover -func coverage.txt
$ go tool cover -func coverage.txt go-scrum-poker-bot/config/config.go:9: NewConfig 100.0% go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0% go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0% go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0% go-scrum-poker-bot/main.go:22: main 0.0% go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0% go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0% go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0% go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0% go-scrum-poker-bot/storage/users.go:25: All 100.0% go-scrum-poker-bot/storage/users.go:34: Save 100.0% go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0% go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0% go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0% go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0% go-scrum-poker-bot/ui/builder.go:26: getResults 100.0% go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0% go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0% go-scrum-poker-bot/ui/builder.go:100: Build 100.0% go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0% go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9% go-scrum-poker-bot/web/clients/client.go:65: Make 66.7% go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0% go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0% go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5% go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0% go-scrum-poker-bot/web/clients/response.go:12: Json 100.0% go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7% go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4% go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0% go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0% go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0% go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0% go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0% go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0% go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0% go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3% go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0% go-scrum-poker-bot/web/server/server.go:31: NewServer 0.0% go-scrum-poker-bot/web/server/server.go:49: setupRouter 0.0% go-scrum-poker-bot/web/server/server.go:67: setupMiddleware 0.0% go-scrum-poker-bot/web/server/server.go:76: Serve 0.0% total: (statements) 75.1%
Неплохо, но нам не нужно учитывать в coverage main.go (мое мнение) и server.go (здесь можно поспорить), поэтому есть хак :). Нужно добавить в начало файлов, которые мы хотим исключить из оценки следующую строчку с тегами:
//+build !test
Перезапустим с тегом:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test
Результат:
go tool cover -func coverage.txt
$ go tool cover -func coverage.txt go-scrum-poker-bot/config/config.go:9: NewConfig 100.0% go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0% go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0% go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0% go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0% go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0% go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0% go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0% go-scrum-poker-bot/storage/users.go:25: All 100.0% go-scrum-poker-bot/storage/users.go:34: Save 100.0% go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0% go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0% go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0% go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0% go-scrum-poker-bot/ui/builder.go:26: getResults 100.0% go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0% go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0% go-scrum-poker-bot/ui/builder.go:100: Build 100.0% go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0% go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9% go-scrum-poker-bot/web/clients/client.go:65: Make 66.7% go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0% go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0% go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5% go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0% go-scrum-poker-bot/web/clients/response.go:12: Json 100.0% go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7% go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4% go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0% go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0% go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0% go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0% go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0% go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0% go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0% go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3% go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0% total: (statements) 90.9%
Такой результат мне нравится больше 🙂
На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/post/545304/
Добавить комментарий