А пусть тесты сами себя и поддерживают

от автора

Сегодня я хочу рассказать о необычном подходе к написанию тестов, к которому я как-то незаметно пришел в ходе работы над несколькими проектами разной величины, и который я почему-то не встречал в чистом виде у других, хотя он, в общем-то, лежит на поверхности. С недавних пор я начал писать кое-какой код на Go, и как только встал вопрос о написании тестов, я опять вспомнил об этом подходе.

Как обычно выглядят тесты?

Очень схематично, каждый юнит-тест обычно состоит из следующих шагов:

  1. инициализации входных данных;
  2. выполнения бизнес-логики и получения результата;
  3. сравнения результата с эталоном.

Входные и выходные данные зачастую находятся в самом коде; когда изменения кода привносят ожидаемые изменения в выходных данных, эталонные результаты приходится править вручную. В некоторых случаях, когда данные для теста объемны, их выносят в отдельные файлы, но поддержка эталонных данных, а так же логика сравнения остается на плечах разработчика.

Но ведь все это можно унифицировать!


Представьте, что в теле ваших юнит-тестов вообще нет сравнения полученных результатов с эталоном. Представьте, что сами тесты могут за вас создавать эталонные данные. Представьте, что все входные и выходные данные лежат в структурированном формате, а код тестов становится более компактным, однородным и читаемым. Представили?

Встречайте agenda-тесты

Я назвал такой подход agenda-тестированием, потому что я люблю аббревиатуры, и agenda — это, на самом деле, auto-generated-data. В чем его суть?

  1. Входные и выходные данные тестов хранятся в файлах (JSON или что-то еще — неважно).
  2. Тест может работать в двух режимах:
    • Режим инициализации: тест производит вычисление выходных данных и сохраняет эти данные в файл-эталон
    • Режим тестирования: тест производит вычисление выходных данных, читает ранее сохраненные эталонные данные и сравнивает их; данные отличаются — тест провален.

  3. Весь вспомогательный код типа чтения, записи и сравнения данных выносятся во вспомогательную библиотеку/функцию/класс, оставляя в индивидуальных тестах только их самую суть.

И это все?.. И это все! Давайте посмотрим, как это работает на примере 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/


Комментарии

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

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