Статьи

Тест управляемых ловушек, часть 1

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

Я был там. Я построил один сам. И это рухнуло убивая меня в процессе. Я усвоил свой урок. Так вот история о мертвеце. Учитесь на моих ошибках или будьте обречены повторять их.

История

Test Driven Development, как и все хорошие игры в мире, проста в освоении, сложна в освоении. Я начал в 2005 году, когда замечательный парень по имени Петр Сарвас подарил мне книгу «Разработка через тестирование: на примере» (Кент Бек) и одну задачу: создание фреймворка.

Это были старые времена, когда у технологии, которую мы использовали, не было вообще никаких фреймворков, и мы хотели иметь классную, такую ​​как Spring, с инверсией управления, объектно-реляционным отображением, Model-View-Controller и всеми хорошими вещами. мы знали о. И поэтому мы создали основу. Затем мы создали систему управления контентом. Затем мы создали кучу специализированных приложений для разных клиентов, интернет-магазинов и чего-то еще, помимо этих двух. У нас все хорошо. У нас было более 3000 тестов для платформы, более 3000 тестов для CMS и еще несколько тысяч для каждого выделенного приложения. Мы смотрели на нашу работу, и мы были счастливы, в безопасности, в безопасности. Это были хорошие времена.

А затем, когда наша кодовая база выросла, мы пришли к тому, что простая анемичная модель, которая у нас была, перестала быть достаточно хорошей. Видите ли, я не читал другую важную книгу того времени: «Дизайн, управляемый доменом». Я еще не знал, что вы можете зайти так далеко с анемичной моделью.

Но мы были в безопасности. У нас были тонны тестов. Мы могли бы изменить что угодно.

Или я так думал.

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

Мне потребовалась неделя, чтобы понять, что я здесь еще не закончил. Рефакторинг не имел видимого конца. И ни в коем случае моя кодовая база не была стабильной, готовой к развертыванию. У меня была ветка в хранилище, которую я переименовал в «Lasciate ogne speranza, voi ch’intrate».

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

Единственными реальными вариантами были: либо оставить все как есть, либо удалить все тесты, и снова написать все с нуля. Я не хотел работать с кодом, если бы мы выбрали первый вариант, и руководство не нашло бы финансового обоснования для второго. Итак, я ушел.

Это было Подземелье, которое я построил, только чтобы найти себя побежденным его монстрами.

Я вернулся к книге и нашел там все, что сделал неправильно. Изложенные. Выделено Как я мог пропустить это? Как я мог не заметить? Оказывается, иногда вам нужно быть совершеннолетним и иметь опыт, чтобы по-настоящему понимать вещи, которые вы изучаете.

Даже самые лучшие инструменты при плохом использовании могут обернуться против вас. И чем проще инструмент, тем легче его использовать, тем легче попасть в ловушку мышления «Я знаю, как это работает». И тогда БАМ! Ты ушел.

Правда

Разработка через тестирование и тесты — это две совершенно разные вещи. Тесты являются только побочным продуктом TDD, не более того. В чем смысл TDD? Что приносит TDD? Почему мы делаем TDD?

Из-за трех и только тех трех причин.

1. Найти лучший дизайн, ставя себя на место пользователя. Начав с размышлений «как я хочу это использовать», мы обнаружим наиболее полезный и дружественный дизайн. Всегда хорошо, довольно часто это лучший дизайн. В противном случае мы получаем следующее:

И ты не хочешь этого.

2. Управлять нашим страхом. Требуются шары, чтобы сделать основательное изменение в большой кодовой базе без тестов, и сказать «все сделано», не внося ошибок в процесс, не так ли? Что ж, правда в том, что если вы говорите «все готово», большую часть времени вы либо невежественны, безрассудны, либо просто глупы. Это как с параллелизмом: все это знают, никто не может сделать это хорошо.

Умные люди боятся таких перемен. Если у них нет хороших тестов, с высоким охватом кода.

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

3. Быстрая обратная связь. Как долго вы можете писать без запуска приложения? Как долго вы можете писать, не зная, работает ли ваш код так, как вы думаете?

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

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

Вот и все. Больше нет причин для TDD. Мы хотим хорошего дизайна, безопасности и обратной связи. Хорошие тесты — это те, которые дают нам это.

Плохие тесты?

Все остальные тесты плохие.

Плохая практика

Так как же выглядит типичный, плохой тест? Тот, который я вижу снова и снова, рядом с каждым проектом, создан кем-то, кто еще не научился, как НЕ строить уродливое подземелье, как не заливать бетон над своей базой кода. Тот, который я написал бы себе в 2005 году.

Это будет образец Спока, написанный на groovy, тестирующий контроллер Grails. Но не волнуйтесь, если вы не знаете эти технологии. Бьюсь об заклад, вы поймете, что там происходит без проблем. Да, это так просто. Я объясню все не столь очевидные части.

1
2
3
4
5
6
7
8
9
def 'should show outlet'() {
  given:
    def outlet = OutletFactory.createAndSaveOutlet(merchant: merchant)
    injectParamsToController(id: outlet.id)
  when:
    controller.show()
  then:
    response.redirectUrl == null
}

Итак, у нас есть контроллер. Это контроллер розетки. И у нас есть тест. Что не так с этим тестом?

Название теста «должен показать розетку». Что должен пройти тест с такой проверкой имени? Покажем ли мы розетку, верно? И что это проверяет? Будем ли мы перенаправлены. Бриллиант? Бесполезный.

Это просто, но я вижу все вокруг. Люди забывают, что нам нужно:

ПРОВЕРИТЬ ПРАВИЛЬНОЕ

Могу поспорить, что тест был написан после кода. Не в тест-первой моде.

Но проверки правильности не достаточно. Давайте рассмотрим другой пример. Тот же контроллер, другое ожидание. Название: «следует создать команду вставки розетки с действительными параметрами с новой учетной записью»

Довольно сложно, не правда ли? Если вам нужно объяснение, имя неверно. Но вы не знаете домен, поэтому позвольте мне осветить его: когда мы даем контроллеру хорошие параметры, мы хотим, чтобы он создал новую OutletInsertCommand, и учетная запись этого должна быть новой.

Название не говорит о том, что является «новым», но мы должны увидеть его в коде.

Посмотрите на тест:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
def 'should create outlet insert command with valid params with new account'() {
  given:
    def defaultParams = OutletFactory.validOutletParams
    defaultParams.remove('mobileMoneyAccountNumber')
    defaultParams.remove('accountType')
    defaultParams.put('merchant.id', merchant.id)
    controller.params.putAll(defaultParams)
  when:
    controller.save()
  then:
    1 * securityServiceMock.getCurrentlyLoggedUser() >> user
    1 * commandNotificationServiceMock.notifyAccepters(_)
    0 * _._
    Outlet.count() == 0
    OutletInsertCommand.count() == 1
    def savedCommand = OutletInsertCommand.get(1)
    savedCommand.mobileMoneyAccountNumber == '1000000000000'
    savedCommand.accountType == CyclosAccountType.NOT_AGENT
    controller.flash.message != null
    response.redirectedUrl == '/outlet/list'
}

Если вы новичок в Spock: n * mock.whwhat (), означает, что метод «что угодно» из фиктивного объекта должен вызываться ровно n раз. Не больше, не меньше. Подчеркивание «_» означает «все» или «что-нибудь». И знак >> указывает платформе теста на возвращение правого аргумента при вызове метода.

Так что не так с этим тестом? Почти все. Давайте начнем с части «тогда», к счастью пропустив установку чрезмерной громкости в «дано».

1
1 * securityServiceMock.getCurrentlyLoggedUser() >> user

Первая строка проверяет, запрашивалась ли какая-либо служба безопасности для зарегистрированного пользователя, и возвращает пользователя. И это спросили ровно один раз. Не больше, не меньше.

Чего ждать? Почему у нас здесь есть служба безопасности? Название теста ничего не говорит о безопасности или пользователях, почему мы проверяем это?

Ну, это первая ошибка. Эта часть не то, что мы хотим проверить. Это, вероятно, требуется контроллером, но это только означает, что оно должно быть в «заданном». И не следует проверять, что он называется «ровно один раз». Это заглушка ради Бога. Пользователь либо вошел в систему или нет. Нет смысла заставлять его «войти в систему, но вы можете спросить только один раз».

Затем есть вторая строка.

1
1 * commandNotificationServiceMock.notifyAccepters(_)

Он проверяет, что какая-то служба уведомлений вызывается ровно один раз. И это может быть хорошо, бизнес-логика может требовать этого, но тогда … почему это не указано четко в названии теста? Ах, я знаю, имя будет слишком длинным. Ну, это тоже предложение. Вам нужно сделать еще один тест, что-то вроде «следует уведомить о только что созданной команде выхода вставки».

И затем, это третья строка.

1
0 * _._

Мой любимый. Если код — Хан Соло, эта строка — Хижина Джабба. Он хочет, чтобы Ханс Соло застыл в твердом бетоне. Или мертвый. Или оба.

Эта строка, если вы еще не вычитали: «Вы не должны вступать в какие-либо другие взаимодействия с какими-либо насмешками, заглушками или чем-то еще, аминь!».

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

Нет, это не так. Был там, сделал это. Причина, по которой программист использовал бы такую ​​вещь, состоит в том, чтобы убедиться, что он охватил все взаимодействия. Что он ни о чем не забыл. Тесты хорошие, что плохого в том, чтобы иметь больше хорошего?

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

А потом еще одна строка.

1
Outlet.count() == 0

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

Тогда есть часть, которая на самом деле имеет смысл.

1
2
3
4
OutletInsertCommand.count() == 1
def savedCommand = OutletInsertCommand.get(1)
savedCommand.mobileMoneyAccountNumber == '1000000000000'
savedCommand.accountType == CyclosAccountType.NOT_AGENT

Мы ожидаем, что объект, который мы создали в базе данных, а затем мы проверяем, является ли его учетная запись «новой». И мы знаем, что «новый» означает конкретный номер счета и тип. Хотя это кричит о том, чтобы быть извлеченным в другой метод.

А потом…

1
2
controller.flash.message != null
response.redirectedUrl == '/outlet/list'

Тогда у нас есть некоторые флэш-сообщения не установлены. И перенаправление. И я спрашиваю Бога, какого черта мы это проверяем? Не потому, что так говорит название теста, это точно. Правда в том, что, глядя на тест, я могу построчно воссоздать тестируемый метод.

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

Итак, вот еще один урок. Недостаточно проверить правильность. Тебе надо

ПРОВЕРЬТЕ ТОЛЬКО ПРАВИЛЬНОЕ.

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

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

Теперь для другого ужасного примера.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Unroll('test merchant constraints field #field for #error')
def 'test merchant all constraints'() {
  when:
    def obj = new Merchant((field): val)
 
  then:
    validateConstraints(obj, field, error)
 
  where:
    field                     | val                                    | error
    'name'                    | null                                   | 'nullable'
    'name'                    | ''                                     | 'blank'
    'name'                    | 'ABC'                                  | 'valid'
    'contactInfo'             | null                                   | 'nullable'
    'contactInfo'             | new ContactInfo()                      | 'validator'
    'contactInfo'             | ContactInfoFactory.createContactInfo() | 'valid'
    'businessSegment'         | null                                   | 'nullable'
    'businessSegment'         | new MerchantBusinessSegment()          | 'valid'
    'finacleAccountNumber'    | null                                   | 'nullable'
    'finacleAccountNumber'    | ''                                     | 'blank'
    'finacleAccountNumber'    | 'ABC'                                  | 'valid'
    'principalContactPerson'  | null                                   | 'nullable'
    'principalContactPerson'  | ''                                     | 'blank'
    'principalContactPerson'  | 'ABC'                                  | 'valid'
    'principalContactInfo'    | null                                   | 'nullable'
    'principalContactInfo'    | new ContactInfo()                      | 'validator'
    'principalContactInfo'    | ContactInfoFactory.createContactInfo() | 'valid'
    'feeCalculator'           | null                                   | 'nullable'
    'feeCalculator'           | new FixedFeeCalculator(value: 0)       | 'valid'
    'chain'                   | null                                   | 'nullable'
    'chain'                   | new Chain()                            | 'valid'
    'customerWhiteListEnable' | null                                   | 'nullable'
    'customerWhiteListEnable' | true                                   | 'valid'
    'enabled'                 | null                                   | 'nullable'
    'enabled'                 | true                                   | 'valid'
}

Вы понимаете, что происходит? Если вы этого раньше не видели, то можете и не очень. Часть «где» — это прекрасное решение Спока для параметризованных тестов. Заголовки этих столбцов — это имена переменных, используемых ДО, в первой строке. Это своего рода объявление после использования. Тест будет выполняться много раз, по одному разу для каждой строки в части «где». И все это возможно благодаря преобразованию абстрактного синтаксического дерева в Groovy. Мы говорим о интерпретации и изменении кода во время компиляции. Классная вещь.

Так что же делает этот тест?

Ничего.

Позвольте мне показать вам тестируемый код.

01
02
03
04
05
06
07
08
09
10
static constraints = {
  name(blank: false)
  contactInfo(nullable: false, validator: { it?.validate() })
  businessSegment(nullable: false)
  finacleAccountNumber(blank: false)
  principalContactPerson(blank: false)
  principalContactInfo(nullable: false, validator: { it?.validate() })
  feeCalculator(nullable: false)
  customerWhiteListEnable(nullable: false)
}

Это статическое замыкание говорит Grails, какую проверку мы ожидаем на уровне объекта и базы данных. В Java это, скорее всего, аннотации.

И вы не тестируете аннотации. Вы также не тестируете статические поля. Или замыкания без какого-либо разумного кода, без какого-либо поведения. И вы не проверяете, работает ли фреймворк ниже (Grails / GORM здесь), как он работает.

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

Между прочим, этот тест не подтверждает это. Потому что это юнит-тест, работающий над макетом базы данных. Это не тестирование настоящего GORM (Groovy Object-Relational Mapping, адаптер поверх Hibernate). Он тестирует макет настоящего GORM.

Да, это так глупо.

Так что, если TDD дает нам безопасность, дизайн и обратную связь, что дает этот тест? Совершенно ничего. Так почему программист поместил это здесь? Потому что его мозг говорит: тесты хорошие. Чем больше тестов, тем лучше.

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

Итак, вот мой урок номер три:

ОБЕСПЕЧИТЬ БЕЗОПАСНОСТЬ И ХОРОШИЙ ДИЗАЙН

Это был пример того, что что-то пошло не так. Что нам с этим делать?

Ответ: удали это.

Но мне еще предстоит увидеть программиста, который снимает его тесты. Даже такой дерьмовый, как этот. Я полагаю, что к нашему коду мы относимся очень лично Поэтому, если вы сомневаетесь, позвольте мне напомнить вам, что написал Кент Бек в своей книге о TDD:

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

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

[Кент Бек, разработка через тестирование: на примере]
Теперь вы знаете, это безопасно удалить.

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

Ссылка: Test Driven Traps, часть 1 от нашего партнера JCG Якуба Набрдалика в блоге Solid Craft .