Сегодня я хочу рассказать о необычном подходе к написанию тестов, к которому я как-то незаметно пришел в ходе работы над несколькими проектами разной величины, и который я почему-то не встречал в чистом виде у других, хотя он, в общем-то, лежит на поверхности. С недавних пор я начал писать кое-какой код на Go, и как только встал вопрос о написании тестов, я опять вспомнил об этом подходе.
Как обычно выглядят тесты?
Очень схематично, каждый юнит-тест обычно состоит из следующих шагов:
- инициализации входных данных;
- выполнения бизнес-логики и получения результата;
- сравнения результата с эталоном.
Входные и выходные данные зачастую находятся в самом коде; когда изменения кода привносят ожидаемые изменения в выходных данных, эталонные результаты приходится править вручную. В некоторых случаях, когда данные для теста объемны, их выносят в отдельные файлы, но поддержка эталонных данных, а так же логика сравнения остается на плечах разработчика.
Но ведь все это можно унифицировать!
Представьте, что в теле ваших юнит-тестов вообще нет сравнения полученных результатов с эталоном. Представьте, что сами тесты могут за вас создавать эталонные данные. Представьте, что все входные и выходные данные лежат в структурированном формате, а код тестов становится более компактным, однородным и читаемым. Представили?
Встречайте 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убеждаемся, что все изменения данных ожидаемы.
Разделение кода и тестовых данных имеет как достоинства, так и недостатки:
К недостаткам можно отнести большее количество файлов, которые будут присутствовать в коммитах. Для простых юнит-тестов с несложными данными ограниченного объема больше подойдут табличные тесты.
Достоинств же гораздо больше: лучшая читаемость тестов (как кода, так и данных), особенно в случае со сложными структурами тестируемых данных, меньший шанс что-то упустить при проверке результатов, а также возможность пополнения и проверки тестовых данных тестировщиками без необходимости перекомпиляции кода.
ссылка на оригинал статьи https://habrahabr.ru/post/329260/
Добавить комментарий