Skip to content

Latest commit

 

History

History

chapter11

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

11. Тестирование

Морис Уилкс (Maurice Wilkes), разработчик EDSAC, первого компьютера с хранимой программой, в 1949 году столкнулся со случаем поразительного ясновидения, когда поднимался по лестнице в свою лабораторию. В своей книге Memoirs of a Computer Pioneer он пишет, как его "с необычайной силой охватила уверенность, что большая часть жизни будет потрачена на поиск ошибок в собственных программах". Наверняка каждый программист, работающий на компьютере с хранимой программой, отлично понимает Уилкса, хотя, возможно, не без некоторого удивления его наивности в вопросах построения сложного программного обеспечения.

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

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

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

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

Выводы:

  • Тестирование программного обеспечения - это практика написания небольших программ, которые проверяют, что тестируемый код (готовый код) ведет себя так, как от него и ожидается, для определенных входных данных;
  • Go использует простой и эффективный подход к тестированию с помощью команды go test и ряда соглашений по написанию тестовых функций;
  • Написание тестового кода в Go аналогично написанию основного кода и не требует новых обозначений, соглашений или инструментов;
  • Тестирование на Go может помочь обнаружить ошибки и улучшить качество кода, благодаря тщательному отбору тестовых данных и проверке граничных условий;
  • Тестирование на Go дает возможность программистам сосредоточиться на определенных частях задачи и контролировать результаты вычислений с разными входными данными.

11.1. Инструмент go test

  • Команда go test является тест-драйвером для пакетов Go, организованных согласно определенным соглашениям;
  • go test позволяет проверять программную логику (Test), производительность(Benchmark) и машинно-проверяемую документацию (Example)*;
  • Файлы *_test.go представляют собой часть временного пакета main, который создается и запускается go test;
  • go test сканирует файлы *_test.go на предмет наличия тестовых функций, функций производительности и функций-примеров.

11.2. Тестовые функции

  • Каждый тестовый файл в Go должен импортировать пакет testing; это необходимо для корректной работы инструментов тестирования Go;
  • Имена тестовых функций должны начинаться с Test, и далее следует необязательный суффикс, начинающийся с прописной буквы; это соглашение упрощает поиск тестовых функций при использовании команды go test;
  • Параметр t в тестовых функциях предоставляет методы для сообщения о не пройденных тестах и для протоколирования дополнительной информации; это помогает лучше отслеживать поведение тестов и выявлять проблемы в коде;
  • go test используют для компиляции и выполнения тестов;
  • Сначала нужно написать тест, который воспроизводит проблему, чтобы убедиться в ее наличии; это поможет быстрее определить и исправить источник ошибки;
  • Табличное тестирование (table-driven testing) - хороший подход для написания тестовых примеров в Go, так как он позволяет легко добавлять новые записи в таблицу и избегает дублирования проверок и вывода сообщений об ошибках;
  • Тесты в Go не зависят друг от друга; использование методов t.Error или t.Errorf не приводит к панике или остановке выполнения теста, что позволяет узнать о нескольких сбоях при одном запуске теста;
  • В случае, если необходимо остановить тестирование после определенной ошибки, нужно использовать методы t.Fatal или t.Fatalf, но вызывать их нужно только из той же горутины, что и функция тестирования;
  • Формат сообщений об ошибках в тестах должен быть понятным и информативным, включая информацию о выполняемой операции, фактический результат и ожидаемый результат; такой подход сэкономит время при диагностике ошибок и поможет быстрее исправить проблемы;

11.2.1 Рандомизированное тестирование

  • Рандомизированное тестирование используется для проверки функций на более широком спектре входных данных, создавая их случайным образом;
  • Для проверки результатов рандомизированного тестирования можно использовать две стратегии: написать альтернативную реализацию функции с более простым алгоритмом или создавать входные значения по определенному шаблону, для которого известен ожидаемый результат;
  • Рандомизированные тесты недетерминированны, поэтому нужно записывать достаточно информации для воспроизведения сбоев, например, инициализирующее значение генератора псевдослучайных чисел;
  • Использование текущего времени в качестве источника случайности обеспечивает тестирование с новыми наборами входных данных при каждом запуске, что особенно полезно при автоматизированном выполнении тестов;
  • Рандомизированное тестирование может осуществляться с помощью функций, генерирующих случайные входные данные, и обработки результатов с использованием стандартного пакета "testing";
  • Использование рандомизированного тестирования позволяет охватить больше потенциальных случаев, на которых функция может работать некорректно, улучшая таким образом надежность кода и уменьшая вероятность ошибок в будущем.

11.2.2 Тестирование команд

  • Пакет с именем main может быть импортирован как библиотека, что позволяет использовать его функции для тестирования;
  • Разделение программы на две функции, одна из которых выполняет основную работу, а другая обрабатывает входные данные, облегчает тестирование и делает программу более модульной;
  • Добавление параметров в функцию может уменьшить зависимость от глобальных переменных и упростить тестирование;
  • Использовать io.Writer для записи результата функции позволяет подставить другую реализацию во время тестирования, упрощая проверку результатов работы функций;
  • Тестовый код находится в том же пакете, что и основной рабочий код, что позволяет использовать функции и переменные пакета для тестирования;
  • Создание таблицы с тестовыми данными упрощает добавление новых тестовых примеров и улучшает структуру тестового кода;
  • Ошибка в тесте должна описывать неудачную операцию, фактическое и ожидаемое поведение, что облегчает понимание причины проблемы;
  • Тестируемый код не должен вызывать функции, такие как log.Fatal или os.Exit, чтобы избежать непредвиденного завершения тестирования; сообщать об ожидаемых ошибках можно, возвращая ненулевое значение ошибки;
  • В случае паники тест-драйвер выполнит восстановление, и тест будет считаться не пройденным.

11.2.3 Тестирование белого ящика

  • Тестирование черного ящика подразумевает, что доступна только информация о предоставляемом API и документации, а внутренние компоненты пакета закрыты от взгляда (непрозрачны). Этот подход позволяет тестировать пакеты без знания их внутренней структуры, что делает его более универсальным и применимым к различным пакетам;
  • Тестирование белого ящика - подход к тестированию, при котором тест имеет привилегированный доступ к внутренним компонентам и структурам данных пакета;
  • Тестирование белого ящика дополняет тестирование черного ящика, обеспечивая более детальное покрытие сложных частей реализации;
  • В Go тесты белого ящика могут проверять инварианты типов данных пакета и иметь доступ к неэкспортируемым функциям и переменным;
  • Можно заменять части рабочего кода на легко тестируемые "поддельные" реализации (var notifyUser = func(username string, msg string) {/*...*/}), которые проще настраивать, более предсказуемы, надежны и изучаемы;
  • В Go для временного сохранения и восстановления глобальных переменных можно использовать defer;
  • Использование глобальных переменных безопасно при тестировании, так как go test обычно не запускает несколько тестов одновременно;
  • Тестирование белого ящика в Go позволяет лучше понять и контролировать поведение внутренних компонентов программы и улучшить степень покрытия тестами различных случаев использования.

11.2.4 Внешние тестовые пакеты

  • Внешние тестовые пакеты позволяют обойти циклические импорты и проводить тесты между пакетами разного уровня; это полезно для интеграционных тестов, которые проверяют взаимодействие между компонентами;
  • Внешние тестовые пакеты объявляются в отдельном файле с суффиксом _test в имени пакета, что позволяет go test идентифицировать этот пакет для тестирования;
  • Можно использовать инструмент go list для определения типов файлов Go в каталоге пакета, например, объявления рабочего кода и внешних тестов;
  • В некоторых случаях внешним тестовым пакетам может потребоваться привилегированный доступ к внутреннему представлению тестируемого пакета; для этого нужно использовать файл с суффиксом _test.go внутри тестируемого пакета, что позволяет "через черный ход" предоставить доступ к некоторым внутренним функциям;
  • Файл export_test.go может использоваться для экспорта некоторых функций и переменных, доступных только для внешних тестов, тем самым обеспечивая согласованность поведения внутри и вне пакета;
  • Использование внешних тестовых пакетов делает код проекта на языке Golang более модульным, упрощает процесс тестирования и повышает уровень контроля над доступом к внутренним объектам и функциям.

11.2.5 Написание эффективных тестов

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

11.2.6 Избегайте хрупких тестов

  • Хрупкие тесты — это тесты, которые дают ложные сбои при внесении корректных изменений в программу.
  • Эти тесты часто называют детекторами изменений или тестами статус-кво, которые сбоят при почти любом изменении рабочего кода.
  • Время, затраченное на работу с такими тестами, может нивелировать все преимущества, которые они предоставляют.
  • Хрупкие тесты возникают из-за сложных входных или выходных данных тестируемой функции.
  • По мере развития программы части выходных данных, скорее всего, будут изменяться, что может вызвать ложные срабатывания тестов. Это касается и сложных входных данных, которые могут перестать быть корректными.
  • Чтобы избежать хрупких тестов, следует проверять только нужные свойства и быть избирательным в проверках.
  • Нужно тестировать более простые и стабильные интерфейсы программы, а не внутренние функции.
  • Большие функции могут быть полезными для извлечения из сложных выводов тестируемой функции самой сути вывода и более точной проверки.
// TestCurrentTime проверяет точное совпадение времени, что не является
// практичным, так как время изменяется каждую секунду. Этот тест будет давать
// ложные сбои при каждом запуске.
func TestCurrentTime(t *testing.T) {
	// Хрупкий тест: проверяет точное совпадение времени
	expected := "2022-01-01 15:04:05"
	result := currentTime()

	if result != expected {
		t.Fatalf("Expected %s but got %s", expected, result)
	}
}
// TestCurrentTimeGood проверяет только формат времени, не требуя точного
// совпадения. Это делает его менее хрупким и надежным при внесении изменений
// или при повторных запусках.
func TestCurrentTimeGood(t *testing.T) {
	// Нормальный тест: проверяет только формат времени
	result := currentTime()
	_, err := time.Parse("2006-01-02 15:04:05", result)

	if err != nil {
		t.Fatalf("Time format is incorrect: %s", err)
	}
}

// currentTime() возвращает текущее время в формате строки.
func currentTime() string {
	return fmt.Sprintf(time.Now().Format("2006-01-02 15:04:05"))
} 

11.3. Охват

  • Тестирование никогда не является полным и может только увеличивать уверенность в работоспособности кода;
  • Охват теста (покрытие) описывает степень исследования тестируемого кода тестами, но не может быть выражен в строгих количественных значениях;
  • Эвристика охвата инструкций – наиболее распространенный способ оценки охвата тестов, показывает долю инструкций, выполняемых во время тестирования;
  • Инструмент Go cover встроен в go test и используется для измерения охвата инструкций;
  • Для сбора данных об охвате нужно использовать флаг -coverprofile при запуске go test, что позволяет получить информацию о доли выполненных инструкций и создает журнал данных;
  • Использование флага -covermode=count увеличивает счетчик выполнения инструкций, позволяя определить, какие блоки выполняются чаще, а какие - реже;
  • 100% охват инструкций не всегда возможен или практичен, так как некоторые инструкции могут быть недостижимы или обрабатывать редкие ошибки;
  • Тестирование является прагматичной деятельностью, связанной с компромиссом между стоимостью написания тестов и стоимостью возможных сбоев;
  • Инструменты для работы с охватом помогают определить слабые места и направить усилия разработчика в нужном направлении, однако разработка качественных тестов требует тщательных размышлений.

11.4. Функции производительности

  • Функции производительности в Go используются для измерения производительности программы на разных входных данных;
  • Префикс Benchmark и параметр *testing.B используются для создания функции производительности;
  • Поле N в *testing.B указывает количество итераций, которое будет выполнена измеряемая операция;
  • Измерить производительность можно с помощью команды go test -bench='.' , где регулярное выражение после флага -bench определяет, какие функции Benchmark будут запущены;
  • Настройка GOMAXPROCS влияет на параллельные тесты производительности;
  • При разработке быстрой программы, оптимизация часто сводится к минимизации выделения памяти;
  • Флаг -benchmem используется для отображения статистики распределений памяти;

11.5. Профилирование

  • Преждевременная оптимизация является корнем всех зол: программисты тратят слишком много времени на оптимизацию некритических частей кода, что вредит отладке и поддержке программ.
  • Критический код следует оптимизировать только после его обнаружения, а не априорно, поскольку интуитивные оценки часто оказываются неверными;
  • Профилирование - наилучший способ определения критического кода: это автоматизированный подход к измерению производительности, основанный на сборе статистики событий во время выполнения программы и анализе полученных данных;
  • Go поддерживает множество видов профилирования, включая профиль процессора, профиль памяти и профиль блокировок, позволяя определять различные аспекты производительности кода;
  • Профиль процессора идентифицирует функции, запрашивающие наибольшее процессорное время, записывая события при прерываниях, вызванных операционной системой;
  • Профиль памяти определяет операции, ответственные за выделение наибольшего количества памяти, и опирается на события, записываемые для каждых 512 Кбайт выделенной памяти;
  • Профиль блокировок позволяет выявлять операции, вызывающие самые длительные блокировки горутин, такие, как системные вызовы, операции с каналами и захват блокировок;
  • Для сбора информации профилей в Go используется инструмент go test с соответствующими флагами, однако следует избегать использования нескольких типов профилирования одновременно, так как это может исказить результаты;
  • Использование профилирования в Go позволяет оптимизировать критические части кода, улучшая производительность и качество разработанных программ;
  • Профилирование полезно для длительно работающих приложений и может быть включено в программе с помощью API runtime;
  • Инструмент pprof используется для анализа собранной информации о профилировании, он является стандартной частью дистрибутива Go и вызывается с помощью команды go tool pprof;
  • Для анализа данных с помощью pprof требуются два аргумента: выполняемый файл, который создал профиль, и журнал профиля;
  • Имена функций не включены в журнал профилирования для экономии памяти, вместо этого функции идентифицируются по адресам, поэтому выполнимый файл требуется для корректного анализа данных;
  • Во время профилирования, go test сохраняет выполняемый файл как fоо.test, где fоо — имя тестируемого пакета;
  • Рекомендуется профилировать конкретные функции, которые репрезентативны для нагрузки рабочей программы, используя флаг -run=NONE для отключения сравнительного анализа тестовых случаев;
  • Флаг -text отображает текстовую таблицу профилирования с отсортированными функциями в порядке их "горячести", таким образом, упрощая выявление проблем производительности;
  • Флаг -nodecount=10 ограничивает вывод результатов профилирования до 10 строк для упрощения анализа;
  • Для более тонкого анализа проблем могут использоваться графические выводы pprof, требующие установки пакета GraphViz и использования флага -web, который создает аннотированный граф функций программы.

11.6. Функции-примеры

  • Функции-примеры в Go называются Example и не имеют параметров и результатов; их основная роль - служить документацией для библиотечных функций и продемонстрировать взаимодействие между несколькими типами и функциями API;
  • Примеры функций облегчают понимание работы функций, так как они являются реальным кодом Go, проверяемым во время компиляции, что обеспечивает их актуальность и предотвращает устаревание;
  • Используя суффикс Example, веб-сервер документации godoc связывает функции-примеры с функцией или пакетом, который они иллюстрируют, облегчая поиск и понимание соответствующей информации;
  • Функции-примеры являются выполнимыми тестами, запускаемыми командой go test, которая проверяет правильность вывода стандартного потока описанных в комментарии // Output:.