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

в 7:46, , рубрики: Go, testing, ненормальное программирование, Программирование, тестирование

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

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

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

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

Автор: afan

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js