Как стать автором
Обновить

Другая сторона медали или про недостатки юнит-тестирования

Время на прочтение8 мин
Количество просмотров6.8K

Введение

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

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

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

Почему функциональное программирование? Так тестируем же мы почти исключительно функции.

Прощание с иллюзиями или 33 банальности

Это, в общем, не секрет, что даже 100% покрытие тестами не гарантирует правильного поведения программы. Для примера глянем на код:

def f( a, b):
    x = 0
    if a:
        x += 2
    else:
        x += 0
    
    if b:
        x += 2
    else:
        x += 0

    return x

# и полное покрытие

assert f(True, False) == 2
assert f(False, True) == 2

Мы прошли по всем веткам, всё замечательно, но вот доказали ли мы, что функция f всегда возвращает двойку?

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

Поэтому перейдём к property-base testing: это знаменитый QuickCheck из Haskell, GAST из Clean, Kotlintest, QCheck из Ocaml, Hypothesis для Python'а и другие. Они покрывают сразу большое и случайное количество параметров тестируемой функции. Увы и ах, но это тоже не серебряная пуля: у них есть свои особенности, свои проблемы и области применимости.

На языке физики, в первом приближении эти библиотеки гоняют Монте-Карло, перебирая разные пути выполнения или разные варианты редукции графа, как траектории пролёта элементарной частицы. Прямо как в Geant4 мы задаём "источники" (генераторы), "рисуем геометрию" (записываем свойства) и запускаем расчёт (когда на 5 секунд, а когда и на сутки).

И ровно также, как в физике высоких энергий, из-за относительно малого количества запусков мы можем пропустить что-то очень интересное. Мне доводилось видеть, как даже 20 000 прогонов не хватало, чтобы найти ошибку в функции, перемножающей полиномы в символьном виде — требовалось 50 000 запусков. И даже миллион траекторий не может гарантировать того, что не вылезет ошибка на втором миллионе.

В общем, property-based testing — это эксперимент, который надо уметь ставить, и его результаты тоже надо уметь обрабатывать. Об этом прекрасно рассказал автор QuickCheck в видео John Hughes — Building on developers' intuitions (...) | Lambda Days 19. У программистов же далеко не всегда есть возможность и умение вникнуть во все эти дисперсии, распределения...

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

И, наконец, контрольный вопрос: что покажет тестирование свойства ниже?

propertyDoubleEq :: Double -> bool
propertyDoubleEq x = (x == x)

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

2 + 2 = 5? А если протестирую?

Эту нехитрую, но глубокую мысль я увидел здесь, на Хабре, в одном из комментариев ув. Jef239.

Заметьте, что если юнит-тест мы используем для проверки нового кода, то желательно, чтобы код и проверяющий его тест писали разные люди. Ведь если программист по какой-то причине решит, что месяц Январь следует за Февралём, то он так и напишет в двух местах, причём ещё и методом copy-paste:

string monthName(unsigned int n) {
    static vector<string> months = {"Февраль", "Январь", ... };

    return months[n % months.size()];
}

void testMonthNames() {
    assert( monthName(0) == "Февраль");
    ...
}

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

Разумеется, системы типов или даже верификация тут тоже не помогут - спасение именно во "взгляде со стороны". Другой человек, как правило, имеет слегка отличающуюся точку зрения, поэтому он не сделает именно эту ошибку. А значит будет выявлено несовпадение, и тексты исправят.

Представьте, к примеру, что тест написан другим программистом, у которого год начинается с марта. Они поспорят, может быть даже подерутся, но в конце-концов непременно отыщут истину!

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

Юнит-тесты — это код

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

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

  2. Кто-то его должен отладить. Как это ни смешно, в тестах тоже бывают ошибки. Их, конечно, отладить проще, тем не менее, это таки надо сделать.

  3. Кто-то должен отрецензировать эти тесты, потратить уйму времени, ведь тесты длинные (см. пункт 1). Ужасный расход средств, дорогого времени старших программистов, тимлидов. Так и в трубу вылететь недолго, если это стартап!

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

  1. Кто-то должен поддерживать тесты. Если мы говорим не об одной из Стандартных Библиотек, тестируемый код и требования к нему будут меняться. Непонятно столько раз за время жизни кода и программиста, но обязательно. Значит придётся подправлять и юнит-тесты, разбираться в них, а ведь кода там много, значит опять потери.

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

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

Юнит-тесты — это запускающийся код

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

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

  1. Прогон тестов занимает физическое время. То есть, программист вынужден оторваться от задачи в лучшем случае на несколько секунд, а в худшем — пойти пить чай, т.к. до вечера тесты всё равно не пройдут. А ведь время итерации write-check-correct loop — это важнейшая характеристика, напрямую влияющая на производительность программиста.

    Кстати, полный набор тестов компилятора Ocaml прогоняется примерно за час — не то, чтобы критично, но при работе с какой-то частью компилятора приходится делать "стенд" — выделять отдельный тест(ы) и прогонять его за секунды. Хотя все тесты совершенно по-делу, отмечу для протокола, что весь компилятор с нуля собирается минуты за две.

  2. Прогон тестов занимает машинное время с соответствующим расходом машин и электроэнергии. Где-то это не критично, а где-то таки да.

  3. Интеграция медленных тестов и CI замедляет процесс рецензирования - если рецензент в какой-то момент проверит код при неоконченном CI процессе, а какой-то тест из набора через пол часа провалится, то рецензенту придётся повторять проверку. И наоборот, если рецензент нашёл опечатку в комментарии, то после исправления тесты придётся прогонять снова, и всё это время автор изменения не сможет выкинуть свой PR из головы.

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

Юнит-тесты — это работающий код без пользователей

Очень важный момент заключается в том, что по-сути, у юнит-тестов нет пользователя, кроме их автора. То есть, нет человека, который в них заинтересован, готов их подправлять, поддерживать. У обычной программы, как правило, есть пользователи, которые либо сами правят, либо находят ошибки и пишут автору. За счёт этого даже какие-то древние вещи вроде WindowMaker, Quake I, Heroes 2 до сих пор живут и здравствуют, портируются на другие системы, развиваются. Даже если программа заморожена, как TeX, с пользователями она живёт, а без пользователей умирает.

Это же относится и к таким программам, как юнит-тесты. Это ведь программы, а не просто письмена на жёстком диске. А значит, они тоже могут деградировать и портиться — в компьютерном мире как нигде "всё течёт, всё меняется".

Как сказано выше, неизбежно качество и документация юнит-тестов не может, да и не должна быть выше, чем качество основного кода. Значит, их труднее поддерживать, а какого-то положительного эффекта для работника от их обновления почти нет. This does not add business value, right?

Смотрите, если вы — разработчик, и у вас какой-то тест протух по каким-то внешним причинам, и не пускает ваши изменения в общий репозиторий, то у вас есть два выхода: исправить тест или обойти его. Причём соблазн обойти иногда крайне велик — при переходе, скажем, с Python 2 на Python 3 или серьёзном обновлении библиотек Boost, портирование даже короткой программы может занять дни. И всё это время вам будут капать на мозги, что же это ваша работа не сделана!

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

То есть, тесты гарантированно "защищают" основной код программы лишь ограниченное время — год, два, три.

Заключение

Вроде бы я перечислил всё, что накопилось на душе. Наверняка это не всё, и наверняка часть из претензий надумана. Например, я совершенно не рассмотрел интеграционные тесты, не затронул mutation testing, без сомнения упустил ещё что-то важное.

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

Разумеется, юнит-тесты — это мощнейший инструмент, но как у любого инструмента, их применение имеет свою цену. Лишь "идеальная система – это система, которой нет, а её функции выполняются".

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

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

Теги:
Хабы:
+15
Комментарии100

Публикации

Изменить настройки темы

Истории

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн