Сегодня я хочу рассказать о необычном подходе к написанию тестов, к которому я как-то незаметно пришел в ходе работы над несколькими проектами разной величины, и который я почему-то не встречал в чистом виде у других, хотя он, в общем-то, лежит на поверхности. С недавних пор я начал писать кое-какой код на 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 убеждаемся, что все изменения данных ожидаемы.

Разделение кода и тестовых данных имеет как достоинства, так и недостатки:

К недостаткам можно отнести большее количество файлов, которые будут присутствовать в коммитах. Для простых юнит-тестов с несложными данными ограниченного объема больше подойдут табличные тесты.

Достоинств же гораздо больше: лучшая читаемость тестов (как кода, так и данных), особенно в случае со сложными структурами тестируемых данных, меньший шанс что-то упустить при проверке результатов, а также возможность пополнения и проверки тестовых данных тестировщиками без необходимости перекомпиляции кода.
Поделиться с друзьями
-->

Комментарии (29)


  1. HedgeSky
    23.05.2017 10:59
    +1

    Интересный подход, но приверженцы TDD в пролёте.


    1. afan
      23.05.2017 11:23
      -1

      По-моему все это очень даже в духе TDD. Тесты никуда не уходят, их только создавать и поддерживать становится легче. Схема такая: пишем сначала тест, формирующий API бизнес-логики, и подготавливаем для него входные данные (тест не проходит); пишем основной код приложения; проверяем код через запуск теста в режиме инициализации и генерацию эталона; rinse & repeat. Можно, конечно, и эталоны генерить руками, только зачем? Главное понимать, какие данные должны быть на выходе (сформулировать для себя эталонный вывод теста), а формально файлик пусть компьютер создает. Ну и коммитить тест и результаты теста вместе с основным бизнес-кодом.


      1. HedgeSky
        23.05.2017 11:32
        +2

        С одной стороны, звучит логично. С другой стороны, ошибиться при проверке данных легче, чем при написании их с нуля. Ошибки при ctrl+C/ctrl+V из этой оперы, например.
        P.S. мне понравилась идея; просто проверяю её на прочность и ищу скрытые подводные камни.


        1. ApeCoder
          23.05.2017 11:43
          +2

          Подводный камень — то, что получающйся тест плохой.


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

          Если делать pinning test, то можно и таким пользоваться


          1. afan
            23.05.2017 11:55

            Ничто не мешает добавлять комментарии в тестовые данные в любом удобном виде. Тогда понятно, зачем нужна именно такая комбинация данных.


            Можно поспорить, что считать хорошим или плохим тестом; с традиционными юнит-тестами я сталкивался как раз с тем, что в юнит-тесте воспроизводился кусок бизнес-логики, создающий объекты и подготавливающий данные, просто потому что это было легко. В итоге тест был плохой, потому что ошибка в бизнес-коде воспроизводилась и в тесте, и тест проходил на ура. Что мне ноавится в подходе с эталонными данными, это то, что код теста там гарантированно другой, и результат всегда нагляден (можно посмотреть, обсудить и прокомментировать в системе code review), и шансов скопипастить багу из основногл кода в тестовый сильно меньше из-за другой природы тестов.


            1. ApeCoder
              23.05.2017 11:59

              Ничто не мешает добавлять комментарии в тестовые данные в любом удобном виде. Тогда понятно, зачем нужна именно такая комбинация данных.

              Мне удобен вид, когда каждое данное представляет собой сонстанту. И вход связан с выходом. И лишние для текузего кейза данные вынесены в отдельное место и не дублируются.


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

              Это устранение дублирование. Просто должны быть еще небольшое количество интеграционных тестов + тесты покрывающие само создание данных


              Какие книжки по тестированию вы читали?


        1. afan
          23.05.2017 11:43

          Согласен, ошибку в эталонном файле можно ненароком пропустить. И к тому же данный способ провоцирует на создание большего количества тестов, что приводит к большему количеству эталонных данных (которые все надо тщательно проверять). With great power comes great responsibility...


  1. ApeCoder
    23.05.2017 11:23
    +2

    Это data driven tests и approval tests недостаток такой, что непонятно, из текста теста почему данные должны быть именно такими.


    1. afan
      23.05.2017 11:27
      +1

      Это, разумеется, data driven tests, но только которые сами генерят выходные данные — в этом основная суть подхода.


      1. ApeCoder
        23.05.2017 11:41
        +1

        Дык approval tests же. Примерно то же самое. Есть еще intellitest который геренирует входные данные


        1. afan
          23.05.2017 12:06

          Да, approval tests — это тот же принцип.


      1. sad
        23.05.2017 11:43

        Да, есть такая штука: Gold Master Testing http://blog.codeclimate.com/blog/2014/02/20/gold-master-testing/. Ваш подход очень похож.


        1. afan
          23.05.2017 11:57

          Да, очень-очень близко. Спасибо за ссылку!


      1. xystarcha
        23.05.2017 12:10

        Мне всегда казалось, что основная суть тестов это имплементация ТЗ вторым независимым путем. Именно это дает уверенность в правильности программы и именно это, мне кажется, имел ввиду ApeCoder

        Генерировать данные тестом — это валидно для отслеживания изменения поведения, но не более.


        1. afan
          23.05.2017 12:19
          +1

          Второй раз бизнес-логику в тестах все равно никто не пишет. Все выливается в итоге в подготовку fixtures, запуск куска основного бизнес-кода и в сравнение каких-то параметров выходных данных (при котором легко что-то забыть сравнить). Здесь же сраниваются снэпшоты данных целиком. Да, они генерятся тестами, но после этого их надо проверять, иначе нет смысла все это затевать. Но сам код теста (сравнение снэпшотов данных) как раз по определению отличается от кода бизнес-логики, и чтение готового снэпшота данных, проверенного и отвечающего условиям ТЗ, и дает этот второй независимый путь.


          1. xystarcha
            23.05.2017 12:39

            Вторая имплементация ТЗ <> второе написание бизнес логики. Когда ты готовишь тест ты придумываешь входные данные, потом берешь ТЗ и на бумажке вычисляешь выходные данные. Это и есть вторая миплементация ТЗ для конкретной (дискретной) точки.

            Предполагается, что если в куче дискретных точек первая и вторая миплементации совпали, то все сделано по ТЗ.


            1. afan
              23.05.2017 19:27

              Здесь все так же, за исключением того, что не нужно руками сериализовать выходные данные. Достаточно их сгенерировать и заверить.

              Вычисление данных на бумажке в некоторых случаях (сложные структуры данных) сложно или просто невозможно. Пример: парсеры, где на входе и на выходе может быть объемный документ или дерево. если все делать руками, начинаются упрощения, попытки обойтись тестовыми данными поменьше и попроще. В случае же, когда вручную готовить нужно только входные данные, задача упрощается.

              Работа со слепками данных позволяет автодокументировать работу как отдельных компонентов, так и всей системы, и выявлять такие ошибки, которые на бумажке не обнаружишь. Пример: функция должна выдавать некую структуру с полями A, B и C. На бумажке все хорошо, в тесте все хорошо — мы явно проверяем наличие этих трех полей и их значения. И все совпадает с ТЗ. А посмотрев на слепок данных вдруг оказывается, что с какого-то момента функция начинает еще возвращать и поле D. В ТЗ это не прописано, но такая ошибка может быть совсем не безобидной (приводить к утечке конфиденциальных данных, например).


              1. ApeCoder
                23.05.2017 21:18
                +1

                Вычисление данных на бумажке в некоторых случаях (сложные структуры данных) сложно или просто невозможно. Пример: парсеры, где на входе и на выходе может быть объемный документ или дерево. если все делать руками, начинаются упрощения, попытки обойтись тестовыми данными поменьше и попроще.

                Это и правильно — тестовые данные должны быть максимально независимы и понятны.


                Иначе при падении тестов будет непонятно, какое требование нарушилось


                1. afan
                  23.05.2017 21:40

                  В теории — правильно (иметь минимально достаточный объем тестов, полностью покрывающий все сценарии). На практике могут быть случаи, когда проблема может проявиться только на каком-то особом наборе данных. Так что я считаю, что лучше поймать ошибку так (с помощью избыточных тестов), чем не поймать ее вообще. От падения такого теста уже можно плясать и смотреть, какого максимально независимого и понятного теста не хватает.


                  1. ApeCoder
                    23.05.2017 22:32

                    Да, в качестве небольшого количества тщательно подобранных тестов это можно использовать


          1. xystarcha
            23.05.2017 12:43

            Проверка уже существующих данных, которые на 99% правильные не так эффективна, как независимый расчет без подсказок.


  1. ggo
    24.05.2017 10:27

    Режим инициализации: тест производит вычисление выходных данных и сохраняет эти данные в файл-эталон
    Режим тестирования: тест производит вычисление выходных данных, читает ранее сохраненные эталонные данные и сравнивает их; данные отличаются — тест провален.

    Аналогичный подход применяется в библиотеках тестирования VCR, Betamax. Но там это делается для создания mock'ов над внешними вызовами, чтобы по время тестов не звать внешний сервис.

    В вашем же случае, мне видится полезный кейс только в случае большого количества тестовых сценариев. Как пример, разрабываем браузер, берем определенную версию, натравливаем на 10 тыс сайтов, сохраняем их скриншоты. Берем следующую версию браузера, получаем новые скриншоты, сравниваем с предыдущими, выявляем разницу. Т.е. имеем условный черный ящик с нетривиальной логикой, хотим иметь оценку, насколько следующая версия сильно отличается от предыдущей.


    1. afan
      24.05.2017 20:03

      Подход можно использовать для реализации разных типов тестов:

      • При тестировании юнитов на небольшом наборе изолированных и специально подготовленных тестов получается, по сути, обычное табличное юнит-тестирование, с тем лишь отличием, что данные находятся в файлах (со всеми плюсами и минусами хранения данных отдельно от тестов; см. data-driven testing и TableDrivenTests), и что эталонные данные не надо писать руками (но обязательно заверять и относиться к ним так, как если бы вы их писали руками).
      • При тестировании больших кусков кода (типа проверки работы API и возвращаемых им стурктур данных) или даже приложения целиком (запускаем приложение с входными параметрами, получаем что-то на выходе, сравниваем выход) получаем интеграционное тестирование. В этом случае входные и выходные данные могут быть более объемными, чем при юнит-тестировании, так что их хранение во внешних файлах более оправданно, как и автогенерация эталонного вывода с последующей его заверкой.
      • При использовании избыточного количества входных данных теста и незаверенных выходных данных получаем pinning tests (спасибо ApeCoder за правильную терминологию). В этом случае мы тестируем не правильность исходной работы приложения, а лишь фиксируем изменения его поведения (что можно делать как, например, отчет в рамках автоматической сборки билдов).


  1. kotbajan
    24.05.2017 12:12

    Реализовывал схожую систему, но не для юнит тестирования, а для функционального (чтобы не соврать с терминологией — программа является черным ящиком, тесты общаются с ней исключительно через UI). Необходимо было протестировать множество отчетов на разных наборах данных.

    Остановился на таком формате тестов — тестовый скрипт, содержащий набор данных, настроек и названия отчетов. При первом прогоне собирались эталонные отчеты. Эталоны проверялись руками, или не проверялись вовсе (когда количество данных было запредельным и смысл был только в отлове изменений, суть есть регрессионное тестирование). При последующих запусках эталон и текущий отчет скармливались diff-у и при наличии изменений тест помечался как имеющий различия, но не упавший. Тест падал только при невозможности выпустить отчет или ввести данные.
    Для ручного тестирования, вместо того чтобы выполнять ввод исходных данных и выпуск отчета, было проще написать новый тест (не содержащий эталонных данных), запустить его, подождать пару часов (думаете отчеты быстро выпускаются? И как только раньше руками тестировал...), получить результаты, проверить и добавить новый тест в базу тестов.


  1. BekzhanKassenov
    24.05.2017 17:30
    +1

    Такой же подход применяется при тестировании олимпиадных задач. Пишется модельное решение и тестируется вручную. Затем генерируются тесты (генератором), и правильные ответы (модельным решением). После этого, результаты, полученные с помощью модельного тестирования, сравниваются с разультатами участника при помощи чекера (а в простейшем варианте при помощи diff-а).


  1. wheercool
    24.05.2017 18:12

    Рассмотрим такой простой кейс: нужно протестировать функцию к-ая валидирует некоторое значение и выдает список ошибок, если они есть.
    Как протестировать ее используя ваш подход с учетом того, что сам порядок ошибок в списке не имеет значения?


    1. afan
      24.05.2017 19:34

      Если порядок в списке не имеет значения, то список нужно отсортировать, чтобы выход был стабильным. Или (если мы говорим конкретно о Go-реализации), положить ошибки в map[string]string — в этом случае при сериализации в JSON ключи будут идти в сортированном порядке автоматически.


  1. Wyrd
    26.05.2017 20:54

    Имхо, самая большая опасность при таком подходе — в человеческом факторе. Предположим, что у нас есть какой-то сервис, возвращающий UserInfo — структуру из, скажем, пяти полей. Предположим также, что нам понадобилось добавить шестое поле. Добавляем и… все тесты падают — надо добавить новое поле в эталонные данные. И вот тут-то и возникает человеческий фактор: если тестов много, можно легко не заметить, что в одном из эталонов кроме добавления нового поля еще и изменилось значение одного из старых полей… и «исправить» эталон на неверный.


    1. afan
      26.05.2017 21:21

      Согласен, опасность такая есть (как и при любом другом подходе к тестированию, в котором есть человеческий фактор). В моей практике такого пока не случалось; все эталонные данные, которые я сохраняю в файлы, всегда структурированы, поэтому при git diff легко обнаружить такие аномалии. Все изменения шаблонов, например, затрагивают одну и ту же строчку, а в одном эталоне картина изменений другая. Конечно, если какое-то изменение затрагивает очень много эталонных данных (сотни файлов), то шанс что-то пропустить сильно увеличивается. С другой стороны, по самому количеству изменившихся эталонов сразу видно, насколько сильно изменение кода затрагивает поведение системы, и что его надо тестировать тщательнее. То же самое и с code review. Представьте коммит, который включает изменение одного шаблона и коммит, в котором включены десятки изменившихся шаблонов. Понятно, что ко второму внимание всех остальных разработчиков будет более пристальным.