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

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

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

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

О паттерне

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

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

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

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

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

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

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

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

Тип 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)
				}
			})
		}
	}
}

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


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

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


  1. Melpomenna
    02.08.2024 14:41
    +2

    Очень интересно написано)

    Я хоть и c++ разработчик, но мы все стараемся упросить себе жизнь, где unit тесты так же занимает свою часть.

    Очень интересно узнать и подсветить пару моментов.

    1) Какой именно функционал генерации тестов вы хотите реализовать? Они буду генерировать тесты на основе существующего кода и генерации тестовых данных или как-то иначе. Есть ли уже какие-то идеи как хотите это сделать?

    2) пробовали ли запускать при большом количестве тестов /тестовых данных. На сколько сильно результат при этом ухудшается. Конечно же подождать минуту иную не проблема, но интересно послушать были ли подобные замеры. Потому что в вашем примере отличия не сильно велики)


    1. mavissig Автор
      02.08.2024 14:41
      +1

      Привет! Спасибо за внимание к статье)

      1. Концепция генератора пока прорабатывается. Хочется, конечно, автоматическую генерацию тестов, но это фактически сделать сложно, если вообще возможно, так как анализировать логику программы очень сложно.

        Если абстрагироваться от хотелок и посмотреть трезво, то следующими шагами я вижу

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

        • расширение утилиты в анализатор с возможностью добавления тестовой функции на базе шаблона для каждого метода из директории в тестовый файл. Предполагается, что на данном этапе в тестовые и ожидаемые значения будут подставляться типы, используемые в аргументах и возвращаемых значения тестируемого метода. Это не совсем точно из-за возможности использования данных из типа, которому принадлежит метод, но пока подробно не прорабатывал этот вопрос. Хочется, что бы по итогу этого шага утилита генерировала шаблоны, в которые останется только внести ожидаемые и тестовые значения и немного поправить сценарий, так как примитивная генерация сценарий + assert.Equal() уже должна присутствовать на данной стадии

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


  1. vovatelbukhov
    02.08.2024 14:41
    +1

    Goland -> generate -> test for function дает то же самое. А статья зачем?


    1. mavissig Автор
      02.08.2024 14:41

      Привет! Спасибо за уделенное время)
      Посмотрел на сгенерированный код goland и сравнил его со своим, мнение сугубо личное и я готов его пересмотреть, если я объективно не прав:

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

      Минусы кодгена. Как, мне кажется, сразу заметно, что сгенерированная часть не такая гибкая. Её менее удобно использовать для сложных сценариев тестирования, где нужно проверять много аспектов работы функции.

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

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

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


      1. vladjong
        02.08.2024 14:41

        Если все разбивать по функциям грамотно, то преимущества твоего подхода теряются. Не надо себе усложнять жизнь:)


        1. vladjong
          02.08.2024 14:41

          Хорошая идея с testData и expectedData , возьму к себе в проект)


        1. mavissig Автор
          02.08.2024 14:41

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


        1. mavissig Автор
          02.08.2024 14:41

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

          Расскажи пожалуйста подробно, что тебя смущает, это очень важно для меня)