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

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

В этой главе приводится ряд антипаттернов TDD и тестирования, а также способы их устранения.

Не заниматься TDD вообще

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

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

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

Непонимание ограничений этапа рефакторинга

Я участвовал во многих семинарах, моббингах или парных сессиях, когда кто-то прошел тест и находился на стадии рефакторинга. После некоторых размышлений они приходят к выводу, что неплохо бы абстрагировать часть кода в новую структуру; начинающий педант кричит:

Вы не имеете права делать это! Сначала нужно написать тест для этого, мы же делаем TDD!

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

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

Наличие тестов, которые не подведут (или вечнозеленых тестов)

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

В TDD это практически невозможно, если вы следуете первому шагу,

Написать тест и увидеть, что он не работает

Это почти всегда происходит, когда разработчики пишут тесты после написания кода, и/или гонятся за тестовым покрытием, а не создают полезный набор тестов.

Бесполезные утверждения

Вы когда-нибудь работали над системой, сломали тест, а потом увидели вот это?

false was not equal to true

Я знаю, что false не равно true. Это не очень полезное сообщение; оно не говорит мне, что я нарушил. Это симптом того, что я не следую процессу TDD и не читаю сообщение об ошибке.

Возвращаемся к чертежной доске,

Напишите тест, посмотрите, как он работает (и не стыдитесь сообщения об ошибке).

Утверждение на несущественных деталях

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

// not this, now your test is tightly coupled to the whole object
if !cmp.Equal(complexObject, want) {
	t.Error("got %+v, want %+v", complexObject, want)
}

// be specific, and loosen the coupling
got := complexObject.fieldYouCareAboutForThisTest
if got != want {
	t.Error("got %q, want %q", got, want)
}

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

Это пример недостаточно строгого следования "красной" стадии.

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

  • Недостаточно внимания уделяется сообщению об ошибке неудачного теста.

Большое количество утверждений в рамках одного сценария для модульных тестов

Большое количество утверждений может сделать тесты трудночитаемыми и сложными для отладки в случае их отказа.

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

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

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

Не слушать свои тесты

Дэйв Фарли в своем видеоролике "Когда TDD идет не так" отмечает,

TDD дает вам самую быструю обратную связь о вашем дизайне

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

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

Я много раз подчеркивал это в книге и повторю еще раз: слушайте свои тесты.

Чрезмерная настройка, слишком много тестов-дублеров и т.д.

Вы когда-нибудь видели тест с 20, 50, 100, 200 строками кода настройки, прежде чем произойдет что-то интересное в тесте? Приходится ли вам потом менять код, возвращаться к этому беспорядку и жалеть о том, что у вас не было другой карьеры?

Каковы здесь сигналы? Послушайте, сложные тесты == сложный код. Почему ваш код сложный? Должен ли он быть таким?

  • Если в ваших тестах много тестовых дублей, это означает, что код, который вы тестируете, имеет много зависимостей - а значит, ваш дизайн нуждается в доработке.

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

Утечки в интерфейсах

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

Загрязнение интерфейса

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

Вместо этого вы должны экспортировать из своего пакета "голую" структуру со всеми соответствующими методами, оставляя клиентам вашего API свободу объявлять свои собственные интерфейсы, абстрагируясь от подмножества необходимых им методов: например, go-redis экспортирует структуру (redis.Client) для клиентов API.

Вообще говоря, раскрывать интерфейс для клиентов следует только в том случае, если:

  • интерфейс состоит из небольшого и последовательного набора функций.

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

Подумайте о том, какие типы тестовых дублеров вы используете

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

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

  • Если таксономия тестов-двойников не совсем понятна, прочтите мои статьи об именовании тестов-двойников.

Консолидация зависимостей

Здесь приведен код http.HandlerFunc для обработки регистрации новых пользователей на сайте.

type User struct {
	// Some user fields
}

type UserStore interface {
	CheckEmailExists(email string) (bool, error)
	StoreUser(newUser User) error
}

type Emailer interface {
	SendEmail(to User, body string, subject string) error
}

func NewRegistrationHandler(userStore UserStore, emailer Emailer) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		// extract out the user from the request body (handle error)
		// check user exists (handle duplicates, errors)
		// store user (handle errors)
		// compose and send confirmation email (handle error)
		// if we got this far, return 2xx response
	}
}

С первого взгляда можно сказать, что дизайн не так уж плох. У него всего 2 зависимости!

Переоценим дизайн, рассмотрев обязанности обработчика:

  • Разобрать тело запроса на User

  • Использовать UserStore для проверки существования пользователя ❓

  • Использовать UserStore для хранения пользователя ❓

  • Составить письмо ❓

  • Использовать Emailer для отправки письма ❓

  • Вернуть соответствующий http-ответ, в зависимости от успеха, ошибок и т.д. ✅

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

  • А если требования расширятся? Переводы для писем? Отправка SMS с подтверждением? Имеет ли смысл менять HTTP-обработчик, чтобы учесть это изменение?

  • Правильно ли я понимаю, что важное правило "мы должны отправить письмо" находится в HTTP-обработчике?

    • Почему для проверки этого правила необходимо пройти через церемонию создания HTTP-запросов и чтения ответов?

Прислушайтесь к своим тестам. Написание тестов для этого кода в соответствии с TDD должно быстро заставить вас почувствовать себя некомфортно (или, по крайней мере, заставить ленивого разработчика раздражаться). Если это ощущение болезненно, остановитесь и подумайте.

А что, если бы дизайн был таким?

type UserService interface {
	Register(newUser User) error
}

func NewRegistrationHandler(userService UserService) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		// parse user
		// register user
		// check error, send response
	}
}
  • Простота тестирования обработчика ✅.

  • Изменения правил регистрации изолированы от HTTP, поэтому их также проще тестировать ✅.

Нарушение инкапсуляции

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

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

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

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

Сложные табличные тесты

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

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

cases := []struct {
	X                int
	Y                int
	Z                int
	err              error
	IsFullMoon       bool
	IsLeapYear       bool
	AtWarWithEurasia bool
}{}

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

При написании программного обеспечения следует помнить следующее,

Простое - не значит легкое

"Просто" добавить поле в таблицу может быть легко, но это может сделать вещи далеко не простыми.

Резюме

Большинство проблем с модульными тестами обычно связаны с тем, что:

  • несоблюдение разработчиками процесса TDD

  • плохое проектирование

Итак, узнайте о хорошем дизайне программного обеспечения!

Хорошая новость заключается в том, что TDD может помочь вам улучшить ваши навыки проектирования, поскольку, как уже говорилось в начале:

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

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

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


  1. BASic_37
    17.10.2023 12:04
    +1

    не герметичные абстракции... ну-ну... Вы сами то читали это чудо, после Google Translate?