Пишем Slack бота для Scrum покера на Go. Часть 1

от автора

Здравствуйте! Сегодня мы напишем 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *