Сегодня я хочу рассказать о необычном подходе к написанию тестов, к которому я как-то незаметно пришел в ходе работы над несколькими проектами разной величины, и который я почему-то не встречал в чистом виде у других, хотя он, в общем-то, лежит на поверхности. С недавних пор я начал писать кое-какой код на Go, и как только встал вопрос о написании тестов, я опять вспомнил об этом подходе.
Очень схематично, каждый юнит-тест обычно состоит из следующих шагов:
Входные и выходные данные зачастую находятся в самом коде; когда изменения кода привносят ожидаемые изменения в выходных данных, эталонные результаты приходится править вручную. В некоторых случаях, когда данные для теста объемны, их выносят в отдельные файлы, но поддержка эталонных данных, а так же логика сравнения остается на плечах разработчика.
Представьте, что в теле ваших юнит-тестов вообще нет сравнения полученных результатов с эталоном. Представьте, что сами тесты могут за вас создавать эталонные данные. Представьте, что все входные и выходные данные лежат в структурированном формате, а код тестов становится более компактным, однородным и читаемым. Представили?
Я назвал такой подход agenda-тестированием, потому что я люблю аббревиатуры, и agenda — это, на самом деле, auto-generated-data. В чем его суть?
И это все?.. И это все! Давайте посмотрим, как это работает на примере Go, для которого я опубликовал небольшую библиотеку, и которую без труда можно портировать на любой другой язык.
Для начала создадим файл «бизнес-логики»: кода, который мы собираемся тестировать:
Файл example.go
Теперь создадим тест:
Файл example_test.go
Вся магия agenda-теста здесь в строчке:
Которая возьмет все файлы тестов в текущей директории (по умолчанию это файлы с расширением .json), и для каждого запустит переданную в качестве параметра функцию.
Теперь создадим файл с тестовыми данными:
Файл test_data.json
Можно запускать тест в режиме инициализации:
При этом рядом с входным файлом будет создан файл с эталонными данными:
Файл test_data.json.result
Этот файл нужно проанализировать и убедиться, что выход соответствует ожиданиям. Если все хорошо, такой сгенерированный файл, наряду с тестовыми данными, коммитится в репозиторий.
Теперь можно запустить тест в обычном режиме:
Тест, разумеется, должен пройти без ошибок.
Теперь, когда вы вносите изменения в код по ходу жизни проекта, вы будете использовать два сценария работы с такими тестами:
Разделение кода и тестовых данных имеет как достоинства, так и недостатки:
К недостаткам можно отнести большее количество файлов, которые будут присутствовать в коммитах. Для простых юнит-тестов с несложными данными ограниченного объема больше подойдут табличные тесты.
Достоинств же гораздо больше: лучшая читаемость тестов (как кода, так и данных), особенно в случае со сложными структурами тестируемых данных, меньший шанс что-то упустить при проверке результатов, а также возможность пополнения и проверки тестовых данных тестировщиками без необходимости перекомпиляции кода.
Как обычно выглядят тесты?
Очень схематично, каждый юнит-тест обычно состоит из следующих шагов:
- Инициализации входных данных;
- Выполнения бизнес-логики и получения результата;
- Сравнения результата с эталоном.
Входные и выходные данные зачастую находятся в самом коде; когда изменения кода привносят ожидаемые изменения в выходных данных, эталонные результаты приходится править вручную. В некоторых случаях, когда данные для теста объемны, их выносят в отдельные файлы, но поддержка эталонных данных, а так же логика сравнения остается на плечах разработчика.
Но ведь все это можно унифицировать!
Представьте, что в теле ваших юнит-тестов вообще нет сравнения полученных результатов с эталоном. Представьте, что сами тесты могут за вас создавать эталонные данные. Представьте, что все входные и выходные данные лежат в структурированном формате, а код тестов становится более компактным, однородным и читаемым. Представили?
Встречайте agenda-тесты
Я назвал такой подход agenda-тестированием, потому что я люблю аббревиатуры, и agenda — это, на самом деле, auto-generated-data. В чем его суть?
- Входные и выходные данные тестов хранятся в файлах (JSON или что-то еще — неважно).
- Тест может работать в двух режимах:
- Режим инициализации: тест производит вычисление выходных данных и сохраняет эти данные в файл-эталон;
- Режим тестирования: тест производит вычисление выходных данных, читает ранее сохраненные эталонные данные и сравнивает их; данные отличаются — тест провален.
- Режим инициализации: тест производит вычисление выходных данных и сохраняет эти данные в файл-эталон;
- Весь вспомогательный код типа чтения, записи и сравнения данных выносятся во вспомогательную библиотеку/функцию/класс, оставляя в индивидуальных тестах только их самую суть.
И это все?.. И это все! Давайте посмотрим, как это работает на примере Go, для которого я опубликовал небольшую библиотеку, и которую без труда можно портировать на любой другой язык.
Для начала создадим файл «бизнес-логики»: кода, который мы собираемся тестировать:
Файл example.go
package example
import "errors"
type Movie struct {
TotalTime int `json:"total_time"`
CurrentTime int `json:"current_time"`
IsPlaying bool `json:"is_playing"`
}
func (m *Movie) Rewind() {
m.CurrentTime = 0
}
func (m *Movie) Play() error {
if m.IsPlaying {
return errors.New("Movie is already playing")
}
m.IsPlaying = true
return nil
}
Теперь создадим тест:
Файл example_test.go
package example
import (
"encoding/json"
"testing"
"github.com/iafan/agenda"
)
func TestMovie(t *testing.T) {
agenda.Run(t, ".", func(path string, data []byte) ([]byte, error) {
type MovieTestResult struct {
M *Movie `json:"movie"`
Err interface{} `json:"play_error"`
}
in := make([]*Movie, 0)
// в data у нас прочитанный файл с тестовыми данными,
// который надо развернуть в структуру
if err := json.Unmarshal(data, &in); err != nil {
return nil, err
}
out := make([]*MovieTestResult, len(in))
for i, m := range in {
// собственно, "бизнес-логика" теста
// Функция Rewind() изменяет свойства структуры
m.Rewind()
// Play() возвращает nil или ошибку
err := m.Play()
// сохраняем выходные "эталонные" данные
// 1) мы хотим сравнивать поля структуры Movie
// 2) мы хотим сравнивать полученную ошибку или ее отсутствие
out[i] = &MovieTestResult{m, agenda.SerializableError(err)}
}
// полученную выходную структуру сериализуем в бинарные данные
// и возвращем для сравнения или сохранения в файл
return json.MarshalIndent(out, "", "\t")
})
}
Вся магия agenda-теста здесь в строчке:
agenda.Run(t, ".", func(...){...}}
Которая возьмет все файлы тестов в текущей директории (по умолчанию это файлы с расширением .json), и для каждого запустит переданную в качестве параметра функцию.
Теперь создадим файл с тестовыми данными:
Файл test_data.json
[
{"total_time":100,"current_time":0,"is_playing":false},
{"total_time":150,"current_time":35,"is_playing":true},
{"total_time":95,"current_time":4,"is_playing":true},
{"total_time":125,"current_time":110,"is_playing":false}
]
Можно запускать тест в режиме инициализации:
$ go test -args init
При этом рядом с входным файлом будет создан файл с эталонными данными:
Файл test_data.json.result
[
{
"movie": {
"total_time": 100,
"current_time": 0,
"is_playing": true
},
"play_error": null
},
{
"movie": {
"total_time": 150,
"current_time": 0,
"is_playing": true
},
"play_error": "Movie is already playing"
},
{
"movie": {
"total_time": 95,
"current_time": 0,
"is_playing": true
},
"play_error": "Movie is already playing"
},
{
"movie": {
"total_time": 125,
"current_time": 0,
"is_playing": true
},
"play_error": null
}
]
Этот файл нужно проанализировать и убедиться, что выход соответствует ожиданиям. Если все хорошо, такой сгенерированный файл, наряду с тестовыми данными, коммитится в репозиторий.
Теперь можно запустить тест в обычном режиме:
$ go test
Тест, разумеется, должен пройти без ошибок.
Теперь, когда вы вносите изменения в код по ходу жизни проекта, вы будете использовать два сценария работы с такими тестами:
- Если ожидается, что изменения в коде не должны привести к изменениям данных: запускаем
go test
и убеждаемся, что тесты не поломаны. - Если ожидается, что изменения в коде должны привести к изменениям данных: запускаем
go test -args init
, а затем с помощью, например,git diff
убеждаемся, что все изменения данных ожидаемы.
Разделение кода и тестовых данных имеет как достоинства, так и недостатки:
К недостаткам можно отнести большее количество файлов, которые будут присутствовать в коммитах. Для простых юнит-тестов с несложными данными ограниченного объема больше подойдут табличные тесты.
Достоинств же гораздо больше: лучшая читаемость тестов (как кода, так и данных), особенно в случае со сложными структурами тестируемых данных, меньший шанс что-то упустить при проверке результатов, а также возможность пополнения и проверки тестовых данных тестировщиками без необходимости перекомпиляции кода.