В первой части статьи мы написали небольшой веб-сервер, который является бэкендом для нашей информационной системы. Та часть не была особенно интересной, хотя и демонстрировала использование интерфейса и один из приёмов работы с горутинами. И то и другое может быть интересно начинающим разработчикам.
Вторая же часть намного интереснее и полезнее, потому что в ней мы напишем юнит-тесты как для самого сервера, так и для библиотечного пакета, реализующего хранилище данных. Приступим.
картинка отсюда
Итак, напомню, что наше приложение состоит из исполняемого модуля (веб-сервер, API), модуля хранилища (структуры данных сущностей, интерфейс-контракт для поставщиков хранилища) и модулей поставщиков хранилища (в нашем примере пока только один модуль, выполняющий интерфейс для хранения данных в памяти).
Тестировать мы будем исполняемый модуль и реализацию хранилища. Модуль с контрактом не содержит кода, который можно было бы протестировать. Там только объявления типов.
Для тестирования будем использовать только возможности стандартной библиотеки — пакеты testing и httptest. На мой взгляд, их вполне достаточно, хотя существует множество разных фреймворков для тестов. Посмотрите их, возможно они вам понравятся. С моей точки зрения, программы на Go не очень нуждаются в тех фреймворках (разного рода), которые сейчас существуют. Это вам не Javascript, о котором речь пойдёт в третьей части статьи.
Сначала пару слов о методологии тестирования, которую я применяю для программ на Go.
Во-первых, надо сказать, что мне очень нравится Go как раз тем, что он не загоняет программиста в какие-то жёсткие рамки. Хотя некоторые разработчики, справедливости ради, любят сами себя загнать в рамки, принесённые из предыдущего ЯП. Скажем, тот же Роб Пайк, говорил что не видит проблемы в копировании кода, если так проще. Такая копипаста есть даже в стандартной библиотеке. Вместо того, чтобы импортировать пакет один из авторов языка просто скопировал текст одной функции (проверка юникода). При этом в тесте пакет юникода импортируется, так что всё ОК.
Кстати, в этом смысле (в смысле гибкости языка) интересную технику можно использовать при написании тестов. Суть в следующем: мы знаем, что контракты интерфейсов в Go выполняются неявно. То есть мы можем объявить тип, написать для него методы и выполнить какой-нибудь контракт. Возможно даже сами того не подозревая. Это известно и понятно. Однако это работает и в обратную сторону. Если автор какого-то модуля не написал интерфейс, который помог бы нам сделать заглушку для тестирования нашего пакета, то мы можем сами объявить интерфейс в нашем тесте, который будет выполнен в стороннем пакете. Плодотворная идея, хотя в нашем учебном приложении и не пригодится.
Во-вторых, пару слов о времени написания тестов. Как все знают, существуют разные мнения о том, когда нужно писать юнит-тесты. Основные идеи следующие:
- Пишем тесты перед написанием кода (TDD). Таким образом лучше понимаем задачу и задаём критерии качества.
- Пишем тесты во время написания кода или даже чуть позже (будем считать это инкрементальным прототипированием).
- Пишем тесты когда-нибудь потом, если будет время. И это не шутка. Иногда условия таковы, что времени физически нет.
Не думаю, что есть единственное правильное мнение на этот счёт. Поделюсь своим и попрошу читателей высказаться в комментариях. Моё мнение таково:
- Отдельностоящие пакеты разрабатывать по TDD, это реально упрощает дело, в особенности тогда, когда запуск приложения для проверки является ресурсоёмким процессом. Например, я недавно разрабатывал систему мониторинга транспорта по GPS/GLONASS. Пакеты драйверов для протоколов можно разрабатывать только через тесты, поскольку запуск и ручная проверка приложения требует ожидания получения данных от трекеров, что крайне неудобно. Для тестов я снял образцы пакетов данных, записал их в табличные тесты, и не стартовал сервер пока драйверы не были готовы.
- Если структура кода не ясна, то сначала я стараюсь сделать минимальный рабочий прототип. Потом пишу тесты, или даже сначала отполирую код немного и потом только тесты.
- Для исполняемых модулей я сначала пишу прототип. Тесты потом. Очевидный исполняемый код вообще не тестирую (типа можно вынести запуск http-сервера из main в отдельную функцию и вызвать в тесте, но зачем тестировать стандартную библиотеку?)
Исходя из этого, в нашем учебном приложении поставщик хранилища в оперативной памяти был написан через тесты. Исполняемый модуль сервера создавался через прототип.
Начнём с тестов для реализации хранилища.
В хранилище у нас есть метод-фабрика New(), который возвращает указатель на экземпляр типа-хранилища. Также есть методы получения котировок Securities(), добавления бумаги в список Add() и инициализации хранилища данными с сервера Мосбиржи InitData().
Тестируем конструктор (термины ООП применяются вольно, неформально. В полном соответствии с положением ООП в Go).
// тестируем конструктор хранилища
func TestNew(t *testing.T) {
// вызываем тестируемый метод-фабрику
memoryStorage := New()
// создаём переменную для сравнения
var s *Storage
// сравниваем тип результата вызова функции с типом в модуле. просто так
if reflect.TypeOf(memoryStorage) != reflect.TypeOf(s) {
t.Errorf("тип неверен: получили %v, а хотели %v", reflect.TypeOf(memoryStorage), reflect.TypeOf(s))
}
// для наглядности выводим результат
t.Logf("n%+vnn", memoryStorage)
}
В этом тесте без особой необходимости продемонстрирован единственный способ в Go проверить тип переменной — рефлексия (reflect.TypeOf(memoryStorage)). Злоупотреблять этим модулем не рекомендуется. Вызовы тяжёлые, да и вообще не стоит. С другой стороны, что ещё проверить в этом тесте кроме отсутствия ошибки?
Дальше протестируем получение котировок и добавление бумаги. Эти тесты частично дублируют друг друга, но это не критично (в тесте добавления бумаги вызывается метод получения котировок для проверки). Я вообще иногда пишу один тест для всех CRUD-операций для конкретной сущности. То есть в тесте я создаю сущность, читаю её, изменяю, снова читаю, удаляю, опять читаю. Не очень элегантно, но явных недостатков не видно.
Тест получения котировок.
// проверяем отдачу котировок
func TestSecurities(t *testing.T) {
// экземпляр хранилища в памяти
var s *Storage
// вызываем тестируемый метод
ss, err := s.Securities()
if err != nil {
t.Error(err)
}
// для наглядности выводим результат
t.Logf("n%+vnn", ss)
}
}
Тут всё достаточно очевидно.
Теперь тест для добавления бумаги. В этом тесте в учебных целях (без реальной необходимости) мы будем использовать очень удобную методику табличного тестирования (table tests). Суть её в следующем: мы создаём массив неименованных структур, каждая из которых содержит входные данные для теста и ожидаемый результат. В нашем случае на вход мы подаём ценную бумагу для добавления, результатом является количество бумаг в хранилище (длина массива). Далее мы для каждого элемента массива структур выполняем тест (вызываем тестируемый метод с входными данными элемента) и сравниваем результат с полем результата текущего элемента. Получается примерно так.
// проверяем добавление котировки
func TestAdd(t *testing.T) {
// экземпляр хранилища в памяти
var s *Storage
var security = storage.Security{
ID: "MSFT",
}
// Табличный тест
var tt = []struct {
s storage.Security // добавляемая бумага
length int // длина массива (среза)
}{
{
s: security,
length: 1,
},
{
s: security,
length: 2,
},
}
var ss []storage.Security
// tc - test case, tt - table tests
for _, tc := range tt {
// вызываем тестируемый метод
err := s.Add(security)
if err != nil {
t.Error(err)
}
ss, err = s.Securities()
if err != nil {
t.Error(err)
}
if len(ss) != tc.length {
t.Errorf("невереная длина среза: получили %d, а хотели %d", len(ss), tc.length)
}
}
// для наглядности выводим результат
t.Logf("n%+vnn", ss)
}
Ну и тест для функции инициализации данных.
// проверяем инициализацию данных
func TestInitData(t *testing.T) {
// экземпляр хранилища в памяти
var s *Storage
// вызываем тестируемый метод
err := s.InitData()
if err != nil {
t.Error(err)
}
ss, err := s.Securities()
if err != nil {
t.Error(err)
}
if len(ss) < 1 {
t.Errorf("невереный результат: получили %d, а хотели '> 1'", len(ss))
}
// для наглядности выводим результат
t.Logf("n%+vnn", ss[0])
}
В результате успешного выполнения тестов мы получаем: 17.595s coverage: 86.0% of statements.
Вы можете сказать, что неплохо бы для отдельной библиотеки получить 100% покрытия, но конкретно здесь вообще невозможны неудачные пути исполнения (ошибки в функциях), из-за особенностей реализации — всё в памяти, никуда не подключаемся, ни от чего не зависим. Обработка ошибок при этом формально есть, так как возвращать ошибку заставляет контракт интерфейса и линтер требует.
Перейдём к тестированию исполняемого пакета — веб сервера. Тут надо сказать, что поскольку веб-сервер является супер-стандартной конструкцией в программах на Go, то для тестирования обработчиков http-запросов был специально разработан пакет «net/http/httptest». Он позволяет сымитировать веб-сервер, запустить обработчик запроса и записать ответ веб-сервера в специальную структуру. Именно его мы и будем использовать, он очень простой, наверняка вам понравится.
При этом есть мнение (и не только моё), что такой тест может быть не очень релевантен реальной рабочей системе. Можно, в принципе, запустить реальный сервер и вызывать в тестах реальные обработчики подключений.
Правда есть и другое мнение (и тоже не только моё), что изоляция бизнес-логики от систем манипулирования реальными данными — это хорошо.
В этом смысле можно сказать, что мы пишем именно юнит-тесты, а не интеграционные тесты с участием других пакетов и служб. Хотя тут я также придерживаюсь мнения, что определённая гибкость Go позволяет не замыкаться на терминах и писать те тесты, которые наиболее соответствуют вашим задачам. Приведу свой пример: для тестов обработчиков запросов к API я делал упрощённую копию БД на реальном сервере в сети, инициализировал небольшим набором данных и запускал тесты на реальных данных. Но этот подход весьма ситуативен.
Вернёмся к тестам нашего веб-сервера. Для того, чтобы написать тесты, не зависящие от реального хранилища нам нужно разработать хранилище-заглушку. Это совсем несложно, поскольку мы работаем с хранилищем через интерфейс (смотри первую часть). Всё что нам нужно, это объявить какой-нибудь свой тип данных и реализовать для него методы контракта интерфейса хранилища, пусть даже и с пустыми данными. Примерно вот так:
// для целей тестирования бизнес-логики создаём заглушку хранилища
type stub int // тип данных не имеет значения
var securities []storage.Security // имитация хранилища данных
// *******************************
// Выполняем контракт на хранилище
// InitData инициализирует фейковое хранилище фейковыми данными
func (s *stub) InitData() (err error) {
// добавив в хранилище-заглушку одну запись
var security = storage.Security{
ID: "MSFT",
Name: "Microsoft",
IssueDate: 1514764800, // 01/01/2018
}
var quote = storage.Quote{
SecurityID: "MSFT",
Num: 0,
TimeStamp: 1514764800,
Price: 100,
}
security.Quotes = append(security.Quotes, quote)
securities = append(securities, security)
return err
}
// Securities возвращает список бумаг с котировками
func (s *stub) Securities() (data []storage.Security, err error) {
return securities, err
}
// контракт выполнен
// *****************
Теперь мы можем инициализировать наше хранилище заглушкой. Как это сделать? Для целей инициализации тестовой среды в Go какой-то не очень древней версии была добавлена функция:
func TestMain(m *testing.M)
Эта функция позволяет провести инициализацию и запустить все тесты. Выглядит это как-то так:
// подготавливаем тестовую среду - инициализируем данные
func TestMain(m *testing.M) {
// присваиваем указатель на экземпляр хранилища-заглушки глобальной переменной хранилища
db = new(stub)
// инициализируем данные (ничем)
db.InitData()
// выполняем все тесты пакета
os.Exit(m.Run())
}
Теперь мы можем написать тесты для обработчиков API-запросов. У нас две конечных точки API, два обработчика, и, следовательно, два теста. Они очень похожи, поэтому приведём здесь первый из них.
// тестируем отдачу котировок
func TestSecuritiesHandler(t *testing.T) {
// проверяем обработчик запроса котировок
req, err := http.NewRequest(http.MethodGet, "/api/v1/securities", nil)
if err != nil {
t.Fatal(err)
}
// ResponseRecorder записывает ответ сервера
rr := httptest.NewRecorder()
handler := http.HandlerFunc(securitiesHandler)
// вызываем обработчик и передаём ему запрос
handler.ServeHTTP(rr, req)
// проверяем HTTP-код ответа
if rr.Code != http.StatusOK {
t.Errorf("код неверен: получили %v, а хотели %v", rr.Code, http.StatusOK)
}
// десериализуем (раскодируем) ответ сервера из формата json в структуру данных
var ss []storage.Security
err = json.NewDecoder(rr.Body).Decode(&ss)
if err != nil {
t.Fatal(err)
}
// выведем данные на экран для наглядности
t.Logf("n%+vnn", ss)
}
Суть теста такова: создаём http-запрос, определяем структуру для записи ответа сервера, запускаем обработчик запроса, раскодируем тело ответа (json в структуру). Ну и для наглядности печатаем ответ.
Получается что-то вроде:
=== RUN TestSecuritiesHandler
0xc00005e3e0
— PASS: TestSecuritiesHandler (0.00s)
c:UsersdtspYandexDiskgosrcmoex_etfserverserver_test.go:96:
[{ID:MSFT Name:Microsoft IssueDate:1514764800 Quotes:[{SecurityID:MSFT Num:0 TimeStamp:1514764800 Price:100}]}]PASS
ok moex_etf/server 0.307s
Success: Tests passed.
Код на Гитхабе.
В следующей, заключительной части статьи, мы разработаем веб-приложение для отображения графиков реальной доходности акций ETF Мосбиржи.
Автор: DmitriyTitov