Пишем Slack бота для Scrum покера на Go
Здравствуйте! Сегодня мы напишем 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
:
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
, там же где мы создаем экземпляры наших структур и интерфейсов. Но это плохой путь. Еще есть вариант использовать глобальные переменные и просто их импортировать. Но в таком случае становится сложно покрывать проект тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам нужно запустить наш сервер. Напишем метод:
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
:
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.gofunc (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
:
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
:
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
, да и как-то глазу приятнее:
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:
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{})
:
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
транспорта. Однако есть и другие варианты, но этот мне показался удобнее:
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
:
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.gopackage 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
хэндлер:
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
для быстрого формирования ошибок:
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.gopackage 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
клиента:
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.gopackage 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.gopackage 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
:
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
:
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
:
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
:
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.gopackage 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
:
// Существующий код
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
:
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.gopackage 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
:
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.gopackage 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.gopackage 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.gopackage 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.gopackage 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")
}
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