golang

Шаблон unit-тестов на Go

  • суббота, 3 августа 2024 г. в 00:00:09
https://habr.com/ru/articles/833448/

Привет, коллеги!

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

Проблема тестирования

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

О паттерне

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

Требования, которые выдвигались мной при выборе архитектуры тестов:

  • максимально возможная читаемость и простота кода

  • возможность переиспользования кода

  • модульность и гибкость

  • удобство при отладке

  • хорошая шаблонная структура

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

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

 type PatternTest struct {
	name          string
	performAction func(*PatternStruct, string)
	verifyResult  func(*testing.T, *PatternStruct, string)
}

Каждая тестовая функция состоит из нескольких блоков

  • тестовые значения

  • ожидаемые значения

  • слайс тестов

  • цикл для запуска тестов из слайса тестов

func Test_MainPattern(t *testing.T) {
	testData := map[string]any{
		// Тестовые значения
	}

	expectedData := map[string]any{
		// Ожидаемые значения
	}

	tests := []PatternTest{
		{
			name: "test name",
			verifyResult: func(t *testing.T, p *PatternStruct, testName string) {
				/*
					Проверка результатов теста
				*/
			},
		},
	}

	for _, test := range tests {
		p := New()
		t.Run(test.name, func(t *testing.T) {
			test.verifyResult(t, p, test.name)
		})
	}
}

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

Давайте разберем каждый блок отдельно. 

Первым у нас идет блок данных, в основном паттерне указаны всего два это testData и expectedData(тип any поставлен там для примера, лучше конкретно указывать тип тестовых данных), но фактически их может быть столько сколько вам нужно. Можно, к примеру, использовать мапу params для установки точных значений полей в каждом создаваемом объекте, метод которого мы будем тестировать. Короче, простор для творчества.

Блок тестов это слайс, в котором мы и опишем сценарии тестов. Каждый тест реализует поля структуры PatternTest

  • name - нужно для запуска теста по имени, а так же для передачи значений из testData, expectedData и любых других данных в тест

  • performAction - метод для исполнения предваряющих тест сценариев. Если перед тестами не требуется исполнение сценариев, то от этой функции можно отказаться, она является примером того, как можно разделить ответственность в тестах

  • verifyResult - метод в котором и будет происходить сравнение данных с ожидаемыми значениями

Далее цикл запуска тестов. Так как мы в примере предполагаем тестирование именно методов какого-то объекта, то на каждой итерации я создаю этот самый объект через функцию New(). 

Какие плюсы от использования этого паттерна:

  • Повторное использование кода

    • Возможность повторного использования общих действий и проверок между разными тестами. Это уменьшает дублирование кода и упрощает внесение изменений.

  • Модульность и гибкость 

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

  • Улучшение читаемости

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

  • Структурированное тестирование

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

  • Повышение надежности тестов

    • Четкое разделение действий и проверок снижает риск ошибок в тестах. Это помогает избежать ситуаций, когда одно действие случайно влияет на проверку другого сценария.

  • Хорошая организация кода

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

Unit-тестирование

Для тестирования объектов, которые не требуют сложных предварительных сценариев, я предлагаю использовать основной паттерн без функции performAction, она в контексте простых тестов будет только мешать. Так же я бы предложил собрать тестовые и ожидаемые значения в мапу, где ключ - имя теста(PatternTest.name), а значение - слайс тестовых данных. Тесты в таком случае можно исполнять в цикле, как показано ниже. Это немного снижает потенциал отладки, так как при проваленном тесте мы будем видеть не конкретный кейс, а только название теста, например Test_SetFieldString/valid, но фактически я не испытывал сложности при диагностике фейлов, так как мы знаем с какими тестовыми параметрами был провален тест. Это позволит проверить, например, пакет валидных данных и следующим тестом проверить пакет инвалидных значений, не усложняя код и оставляя его столь же гибким как и раньше. Этим шаблоном, на самом деле, можно закрыть большинство задач связанных с юнит тестированием. 

Пример теста

Пример теста для метода p.SetFieldString() который устанавливает значение поля p.FieldString

{
	name: "valid",
	verifyResult: func(t *testing.T, p *PatternStruct, testName string) {
		for i := range testData[testName] {
			err := p.SetFieldString(testData[testName][i])
			if err != nil {
				t.Error(err)
			}
			assert.Equal(t, expectedData[testName][i], p.FieldString)
		}
	},
},

Пример использования шаблона для теста
func Test_SetFieldString(t *testing.T) {
	testData := map[string][]string{
		"valid": {
			"valid string",
			"1234567890",
		},
		"invalid": {
			"",
			"gg",
			"invalid",
		},
	}

	expectedData := map[string][]string{
		"valid": {
			"valid string",
			"1234567890",
		}
	}

	tests := []PatternTest{
		{
			name: "valid",
			verifyResult: func(t *testing.T, p *PatternStruct, testName string) {
				for i := range testData[testName] {
					err := p.SetFieldString(testData[testName][i])
					if err != nil {
						t.Error(err)
					}
					assert.Equal(t, expectedData[testName][i], p.FieldString)
				}
			},
		},
		{
			name: "invalid",
			verifyResult: func(t *testing.T, p *PatternStruct, testName string) {
				for i := range testData[testName] {
					err := p.SetFieldString(testData[testName][i])
					assert.Error(t, err)
					assert.Equal(t, "", p.FieldString)
				}
			},
		},
	}

	for _, test := range tests {
		p := New()
		t.Run(test.name, func(t *testing.T) {
			test.verifyResult(t, p, test.name)
		})
	}
}

Бенчмарки

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

Сейчас рассмотрим как выглядит бенчмарк на основе паттерна показанного выше. Для начала нужно обновить структуру PatternTest, она нам больше не подходит,  так как в ней используется *testing.T, а нужен *testing.B. Можно было бы добавить поле в уже существующую структуру, но я объявлю новую и назову её PatternBench.

type PatternBench struct {
	name          string
	performAction func(*PatternStruct, string)
	verifyResult  func(*testing.B, *PatternStruct, string)
}

Далее пишем сам бенчмарк с сохранением, уже знакомой нам архитектуру

func Benchmark_MainPattern(b *testing.B) {
	testData := map[string]any{
		// Тестовые значения
	}

	expectedData := map[string]any{
		// Ожидаемые значения
	}

	tests := []PatternBench{
		{
			name: "valid",
			verifyResult: func(b *testing.B, p *PatternStruct, testName string) {
				// Вызов тестируемого кода
			},
		},
	}

	for _, test := range tests {
		p := New()
		b.ResetTimer()
		b.Run(test.name, func(b *testing.B) {
			b.StopTimer()
			b.StartTimer()
			for i := 0; i < b.N; i++ {
				test.verifyResult(b, p, test.name)
			}
		})
	}
}

Как видите, архитектура теста совсем не изменилась. Да, немного изменились аргументы, немного поменялся раннер, но если вы смогли прочитать первый пример, то почти гарантировано сможете прочитать и этот. 

Пример использования шаблона для бенчмарка
func BenchmarkPatternStruct_SetFieldString(b *testing.B) {
	testData := map[string][]string{
		"valid": {
			"valid string",
			"1234567890",
		},
		"invalid": {
			"",
			"gg",
			"invalid",
		},
	}

	tests := []PatternBench{
		{
			name: "valid",
			verifyResult: func(b *testing.B, p *PatternStruct, testName string) {
				for i := range testData[testName] {
					p.SetFieldString(testData[testName][i])
				}
			},
		},
		{
			name: "invalid",
			verifyResult: func(b *testing.B, p *PatternStruct, testName string) {
				for i := range testData[testName] {
					p.SetFieldString(testData[testName][i])
				}
			},
		},
	}

	for _, test := range tests {
		p := New()
		b.ResetTimer()
		b.Run(test.name, func(b *testing.B) {
			b.StopTimer()
			b.StartTimer()
			for i := 0; i < b.N; i++ {
				test.verifyResult(b, p, test.name)
			}
		})
	}
}

Один код для тестов и бенчмарков

Теперь давайте потихоньку переходить к шаблону, который позволил бы нам сделать код универсальным, но сохранил бы привычный нам вид. 

Для начала нужно было подумать как мы будем обходить префиксную привязку, так как тесты начинаются с префикса Test, а бенчмарки с префикса Benchmark. Я решил не заморачиваться и просто написать функции-триггеры, которые будут дёргать общую функцию в зависимости от того, что мы запускаем.

func Test_SetFieldString(t *testing.T) {
	TBSetFieldString(t, nil)
}

func Benchmark_SetFieldString(b *testing.B) {
	TBSetFieldString(nil, b)
}

func TBSetFieldString(t *testing.T, b *testing.B) {
	// код общей функции для тестирования
}

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

Далее нам снова нужно немного изменить структуру PatternTest и заменить *testing.T на интерфейс testing.TB это позволит подсунуть нам нужный тип в тестирующую функцию. Так же в сигнатуру verifyResult добавлен аргумент с типом bool для того, что можно было не запускать проверку значений, ведь нам нужна только скорость вычислений, а не результат функции.

type PatternTest struct {
	name          string
	performAction func(*PatternStruct, string)
	verifyResult  func(testing.TB, bool, *PatternStruct, string)
}

Под спойлером приведены результаты бенчмарков одной и той же функции с выполняющимися проверками(922 ns/op) и без(15 ns/op). Заметно, что проверка результатов очень сильно искажает результат.

Результаты замеров

Самое время изменить структуру теста, который находится в слайсе тестов:

tests := []PatternTest{
	{
		name: "valid",
		verifyResult: func(t testing.TB, bench bool, p *PatternStruct, testName string) {

			// Вызов тестируемого кода

			if !bench {
				// Проверка результатов теста если запущен НЕ БЕНЧМАРК
			}
		},
	},
}

Да, у нас появился if в коде, но это становится проблемой только если мы экономим строки(и-то есть вопросики), на скорость это влияет минимально и не помешает провести замер быстродействия.

Теперь осталось пересмотреть процесс запуска теста. Так как я решил пойти по пути наименьшего сопротивления, то просто проверил какой тип тестов сейчас запущен через if else и в зависимости от них запускаю verifyResult() с типом t или b, а так же передаю вторым аргументом булевое значение, через которое мы в тесте будем определять требуется запуск проверок правильности значений или нет.

for _, test := range tests {
	p := New()
	if t != nil {
		t.Run(test.name, func(t *testing.T) {
			test.verifyResult(t, false, p, test.name)
		})
	} else if b != nil {
		b.ResetTimer()
		b.Run(test.name, func(b *testing.B) {
			b.StopTimer()
			b.StartTimer()
			for i := 0; i < b.N; i++ {
				test.verifyResult(b, true, p, test.name)
			}
		})
	}
}

Давайте посмотрим на получившийся шаблон целиком

func Test_SetFieldString(t *testing.T) {
	TBSetFieldString(t, nil)
}

func Benchmark_SetFieldString(b *testing.B) {
	TBSetFieldString(nil, b)
}

func TBSetFieldString(t *testing.T, b *testing.B) {
	testData := map[string]string{
		// Тестовые значения
	}

	expectedData := map[string]string{
		// Ожидаемые значения
	}

	tests := []PatternTest{
		{
			name: "valid",
			verifyResult: func(t testing.TB, bench bool, p *PatternStruct, testName string) {
				// вызов тестируемого кода
				if !bench {
					// Проверка результатов теста
				}
			},
		},
	}

	for _, test := range tests {
		p := New()
		if t != nil {
			t.Run(test.name, func(t *testing.T) {
				test.verifyResult(t, false, p, test.name)
			})
		} else if b != nil {
			b.ResetTimer()
			b.Run(test.name, func(b *testing.B) {
				b.StopTimer()
				b.StartTimer()
				for i := 0; i < b.N; i++ {
					test.verifyResult(b, true, p, test.name)
				}
			})
		}
	}
}

Сейчас осталось заполнить этот код тестовыми и ожидаемыми значениями, прописать сценарий вызова тестируемой функции и дописать проверки над результатом. По итогу мы получаем тесты и бенчмарки, которые можно развернуть за короткий промежуток времени и которые сможем масштабировать  без потери читаемости. 


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