Разделы портала

Онлайн-тренинги

.
Модульное тестирование, наука и математика
23.04.2020 00:00

Автор

Предисловие

Модульное тестирование (unit testing) применяется повсеместно. Кажется, уже никто без него не обходится, все пишут тесты, а их отсутствие в сколь-нибудь серьёзном проекте вызывает, как минимум, непонимание. Однако, многие воспринимают тестирование как некий ритуал, совершаемый для того, чтобы не разгневать "бога программирования". Мол, так надо. Почему? Потому что.

Буду говорить страшные вещи.

Не важно, что брать за единицу тестирования. Не важно, как сгруппированы тесты. Не важно, пишутся ли они до кода или после. TDD или не TDD? Всё равно. Доля покрытия? Наплевать. В конце концов, тестов может совсем не быть. Всё это совершенно не важно. Важно, чтобы выполнялись требования, предъявляемые к ПО.

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

Постойте, причём же тут наука с математикой?

Содержание

  1. Гарантии и вероятности
  2. Программирование как построение теории
  3. Тестирование как доказательство теорем
  4. Что важно, а что нет
  5. Нужно ли тестировать тесты


Гарантии и вероятности

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

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

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

Тест никогда не доказывает отсутствие ошибок и правильность работы программы. Он может доказать только наличие ошибки. Это любопытная особенность нашей работы: мы, программисты, никогда не можем быть уверены на 100%, что наша программа работает правильно.

Но мы можем повышать вероятность.


Программирование как построение теории

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

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

Так и есть, и поэтому перехожу к другой, более важной метафоре.


Тестирование как доказательство теорем

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

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

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

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

Рассмотрим пример. Допустим, мы разработали класс std::vector, и хотим протестировать метод clear. Вернее, хотим "доказать", что он работает правильно.

Что вообще он делает? Полностью очищает контейнер, не изменяя при этом его вместимости (capacity). Значит, на этом уровне мы можем сформулировать сразу несколько "теорем":
  1. После вызова метода clear() размер контейнера равен нулю, то есть метод size() возвращает ноль;
  2. После вызова метода clear() контейнер пуст, то есть метод empty() возвращает true;
  3. После вызова метода clear() вместимость контейнера не меняется, то есть метод capacity() возвращает то же значение, что и до вызова метода clear().
  4. Вызов метода clear() приводит к тому, что у всех элементов, которые находились в контейнере, вызывается деструктор.

Попробуем "доказать" первую из "теорем":
test_case("После вызова метода `clear()` размер контейнера равен нулю")
{
    std::vector<int> v{1, 2, 3, 4};

    v.clear();

    check(v.size() == 0);
}

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

Мы создали контейнер, затем очистили его и проверили, что размер равен нулю. Однако создание контейнера и взятие его размера — это тоже работа с нашей программой, то есть не "аксиомы". Получается, что в доказательстве нашей "теоремы" мы пользуемся другими "теоремами" о корректном конструировании вектора, а также о корректной работе метода size(). И их мы тоже должны "доказать".

Допустим, мы "доказали", что контейнер создаётся правильно с помощью конкретного конструктора std::vector<int> v{1, 2, 3, 4}. Но у вектора есть много разных конструкторов. И что, для каждого из них "доказывать", что размер именно так сконструированного вектора равен нулю после очистки? Это было бы слишком расточительно, поскольку методов у вектора много, и пришлось бы каждый из них тестировать для каждого конструктора. А если вдруг появится новый конструктор? Придётся дублировать для него каждый такой тест, ничего не пропустить, и не ошибиться при копипасте (а она будет, мы же ленивые).

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

Осталось "доказать", что метод size() выдаёт именно размер, то есть текущее количество элементов в контейнере. Но здесь мы поступаем аналогично: отдельный случай для пустого по построению вектора, и отдельный случай для непустого. А эквивалентность конструкторов мы уже "доказали", так что можно брать любой.

И вот только теперь мы можем считать "доказанной" изначальную "теорему".

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


Что важно, а что нет

Если есть такая система утверждений, то не важно, что считать единицей, и как группировать тесты (хотя, действительно, удобно отнести тесты к какой-то конкретной программной сущности — классу или функции — и сложить их в отдельный файл). Важно только то, что "доказаны" все "теоремы". А как они сгруппированы и разбросаны по файлам — наплевать.

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

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

В общем, важно, чтобы тесты формировали теорию о работоспособности программы, а не проверяли отдельные случаи.


Нужно ли тестировать тесты

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

Таким образом наши метафорические "наука", то есть код, и "объективная реальность", то есть тесты, двигаются друг другу навстречу, никогда не достигая идеала, но увеличивая желанную вероятность.

Обсудить в форуме