Непопулярные аспекты тестирования

Непопулярные аспекты тестирования


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

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

Тесты — размножающиеся точки входа

     Сложно поверить, что существует разработчик, который начал задумываться о тестировании, но до этого им никогда не занимался. Скорее всего тесты, которые он писал, были написаны в точке входа в программу (в C,C#,Java это вариации на тему процедуры main), запущены один или более раз до того момента, пока не отработают без падений, и удалены. Этому разработчику удобно думать о том, что тесты это те же точки входа в программу, но только их много, а создавать их просто. Поэтому работающий тест можно не удалять. Кроме того можно до последнего момента вообще избавиться от основной точки входа (то есть если вы разрабатываете консольную программу, то замените её на dll) и использовать только тесты для этой цели. Далее идет мысль, следствием из которой является TDD:

Тест, который никогда не упал — бесполезный тест

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

Тесты — REPL в статически типизированных языках

     Read, Eval, Print, Loop – так расшифровывается REPL. Наверное, во всех динамических языках он существует, например, в Ruby это irb, в общем случае, REPL это консоль, которая тут же выполняет код, который туда ввели. REPL удобен, когда нужно что-то быстро проверить, например, по возрастанию или по убыванию упорядочивает стандартный метод Sort(). Другое его использование предполагает загрузку написанного кода в память и его быстрое тестирование. Зачастую реализации REPL обладает одним хорошим свойством — историю набранных команд можно сохранить в исходный файл, как и результат их выполнения. Это позволяет писать код следующем образом: попробую сделать соединение к серверу google — черт ошибка, а если так — отлично; что там еще надо, а распарсить результат — этот regex должен работать, а протестирую я его на этом наборе данных, что? ошибка?, а если не жадную версию запустить – работает — победа, после этого осталось сохранить контекст, подредактировать его и программа готова.

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

Тесты повышают инертность кода

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

Тесты — типизация в динамических языках

     Для меня тесты открыл человек, который активно пропагандировал руби, а я пытался ему возражать, защищая языки со статической типизацией, ссылаясь на помощь компилятора. Его ответом был — зачем нужна проверка компилятора, если код покрыт тестами. Этим примером можно проиллюстрировать тот факт, что культура тестирования развита больше у программистов на динамических языках. Другое доказательство этого — на запросы «java tdd» и «ruby tdd» google выдает примерно одинаковое число страниц, но на запросах «java» и «ruby» разница существенна.

Тест — теорема, код — доказательство, тестирование — формальная проверка доказательства

     Отчасти это утверждение следствие предыдущего и связано с изоморфизмом Карри-Говарда, но в данную область математики-программирования я еще не углублялся, поэтому это утверждение основано больше на интуиции, чем на строгой теории. Попытаюсь объяснить это утверждение на примере. Пусть программа это эмуляция противопожарной службы лесничества: есть патруль, который 3 раза в день патрулирует некоторый маршрут, есть пожары, которые появляются случайно в разных местах, если пожар попадает в область видимости патруля, то он считается обнаруженным. Следующее утверждение можно считать теоремой (инвариантом программы): по итогам дня необнаруженными пожарами останутся те, и только те пожары, которые находятся в вне зоны действия патруля или те, которые возникли после начала последнего патрулирования. Но с другой стороны это словами описанный тест, проверяющий работы программы. Думаю после этого примера данный аспект тестирования становится ясен.

Коллективная ответственность за код, но личная за тесты

     Работая в команде, мне приходилось часто либо возмущаться, либо слышать возмущения в свой адрес примерно такого содержания: «Какого черта ты трогал мой код?!». На самом деле смысл этой фразы не в том, что кто то обижен за то, что его код изменили, а тем, что изменив код, автор изменения неявно изменил инвариант, который имел ввиду автор кода. Эту ситуацию легко избежать если использовать тестирование — все инварианты, которые есть в коде будут находить воплощение в коде тестов. В этом случае будут раздаваться только вопли «Какого черта ты трогал мой тест?!», от которых можно избавиться, запретив уставом компании изменять чужие тесты. Этот подход позволит добавит гибкости, так как не надо будет согласовывать изменения кода с его автором. Имея ввиду предыдушее утверждение, можно сказать, что такой подход практикуется уже веками — существуют именые теоремы, например, теорема Пифагора, которые имеют множество не именных доказательств.
+37
11 января 2009, 17:35
43
shai_xylyd 38,0

комментарии (29)

+5
neuotq #
Извини за оффтоп, а если я не нажал на «Я соглашаюсь… бла бла бла», а нажал на ссылочку «Комментарии», это считается что я согласился, или нет?
0
shai_xylyd #
Да. Я просто хочу, что бы не было copy-past этой статьи без указания автора (меня) и к тому же, что бы кто-либо делал на ней деньги. Думаю, это пожелания каждого автора на хабре.
0
neuotq #
Ну так надо было посто в конце статьи поставить соответствующий указатель, в виде картинок, и все) Вот например гугло поиск выдаст результат сразу на твой топик, и не будет видно лицензии.
0
shai_xylyd #
ОК. Как-то не вспомнил о лого.
НЛО прилетело и опубликовало эту надпись здесь
0
gugu #
>> Тест, который никогда не упал — бесполезный тест
Ничего подобного. Если я пишу тест, то я уверен, что потом, после рефакторинга эта часть кода не сломается. И могу не думать о ней.
0
shai_xylyd #
Вы уверены в том, что если эта часть кода сломается, то тест завалиться. То есть уверенности придает его потенциальная способность завалиться.
НЛО прилетело и опубликовало эту надпись здесь
0
kmike #
Суть-то в том, что вы уверены, что часть кода не сломается, именно из-за теста. Т.к. он «упадет», если изменения в программе будут неправильные. Ценность теста именно в том, что он «падает» и сигнализирует о логической ошибке. А тест, который заведомо не будет падать, и писать незачем.

Вы, скорее всего, просто неправильно поняли предложение (или даже одно слово — «упал»), т.к. пишете-то все верно)
0
gugu #
согласно законам Мерфи… если этот код никогда не должен падать, то в него обязательно закрадется ошибка.

понятно, что писать ok( 1 == 1 ) не нужно.
Но вот проверить, что функция вернула неотрицательное количество пользователей — стоит. Пусть этого и не может быть.
0
dfitiskin #
Но для того чтобы убедиться что этот тест написан верно нужно обязательно вернуть отрицательное количество пользователей и убедиться что он «упал».
+2
necromant2005 #
Хорошая выжимка из книги Кента Бека «Экстремальное программирование».
Хотя основная идея это вселить смелость в разработчиков, что можно безболезненно менять любую часть проекта, проводить любой требуемый рефакторинг, а не откладывать все это и пользоваться устаревшими реализациями.
0
jackman #
Хорошая статья, но я не увидел в ней такой не популярный аспект как например то что тесты должны быть детерминированными, я что то упустил или уже это не актуально?
0
shai_xylyd #
Это точно, но иногда этого сложно добиться, например, когда целью программы является моделирование и как следствее в ней активно используются случайные значения. В той задаче, с которой я сталкивался, мне повезло и после анализа я смог свести всю случайность в программе к случайным начальным данным, а сам алгоритм оказался детерминированным и мне удалось для него написать нормальные тесты.
0
VtQveant #
>Тест — теорема, код — доказательство, тестирование — формальная проверка доказательства
Интересная мысль, хотелось бы продолжения. Изоморфизм Карри-Ховарда связан с типизацией, а что будет типом в контексте юнит-теста?
Задача автоматической верификации в общем виде неразрешима, а на практике есть обширная область и свои средства для верификации, ориентированные на более частные случаи. Я склоняюсь к мысли, что тестирование — практика дизайна, а не раздел верификации, иными словами, тесты ничего не доказывают.
0
shai_xylyd #
Думаю, что когда-нибудь будет — мне эта тема интересна. А само утверждение это просто чистая интуиция, дело в том, что тестирование активно используется программистами, пишущими на динамичиских языках, то есть тестирование для них это замена типизации. Далее я ступаю на почву: согласно изоморфизму, типы есть теоремы, а программы доказатества; в нашем случае, тогда, тесты есть теоремы.
+1
Exxt #
Завалиться, становиться — уберите мягкие знаки…

По теме: «если код будет написан после тестов, то это гарантирует, что тест завалится» — что-то я не поняла этого утверждения, можно поподробнее?
0
dfitiskin #
если написать тест, а код который он тестирует не написать, то тест просто обязан завалиться
0
rg_software #
> Писать книги я не умею, да и опыта мало, но поделиться некоторыми аспектами использования unit тестов я могу

Точно не интересно попробовать формат пошире? Хотя книги по TDD есть, я думаю, тут ещё об очень многих аспектах можно порассуждать.

По крайней мере, я сам в повседневной практике усиленно пытаюсь внедрить TDD, но что-то всё время мешает. Сам я объясняю себе это сложностью и спецификой предметной области, но любой спец, скорее всего, легко раскритикует и объяснит как надо. Так что бы от хорошей книжки не отказался ;)
0
acerv #
Вероятно, вам мешает выбранный вами тест-фреймворк:)
0
VtQveant #
0
sfoid #
> но что-то всё время мешает

Если не сложно, не могли бы вы описать несколько примеров, что мешает?
0
rg_software #
Довольно трудно сформулировать кратко :) Но попробую.
Для начала: мы пишем что-то вроде «конструктора» для создания систем искусственного интеллекта. По существующим спецификациям интерфейса пользователь должен написать свои собственные модули, потом система интегрируется в одно целое. Кроме того, мы поддерживаем инструментарий для отладки и анализа систем, а также выступаем в роли «пользователей», то есть сами пишем интеллектуальные агенты используя свой «конструктор».

Проблемы:
1) cложность и нечёткость предметной области. Если бы я писал библиотеку функций с понятным чётким поведением (тригонометрия, линейная алгебра) вопросов бы не было.
2) (редко, но бывает) ветвистость алгоритмов. Иногда у нас возникает куча кейсов типа «если-то», причём по отдельности каждый кейс выглядит довольно простым. Сочинять для такого тесты, честно говоря, ломает, потому что каждый случай тривиален, а чтобы покрыть большинство вариантов, уходит много кода. Покрыть же небольшую часть смысла мало, т.к. тогда тест становится не очень полезным.
3) в целом не очень большая польза от атомарных тестов в ИИ :) в большинстве случаев откомпилировать программу и заставить её работать по спецификации не очень трудно. Но оказывается, что работает интеллект плохо :) Приходится изменять параметры, влияющие на поведение, добавлять новые анализируемые факторы и т.п. То есть основной «дебаг» заключается в дополнительном анализе предметной области, а не в сверке со спецификацией.
4) (самый важный технический трабл). Наш «конструктор» можно представить себе ещё так. Есть инфраструктура с «дырками», для которой надо написать модули, затыкающие эти «дырки». Друг без друга ничего не работает. Чтобы запустить систему, нужны все модули (хотя бы в виде заглушек). Соответственно, чтобы тестировать инфраструктуру, нужны все модули (пусть хотя бы mock objects). А чтобы тестировать модули, нужна инфраструктура (впрочем, это уже не очень страшно). При этом всё развивается, иногда приходится менять слегка инфраструктуру под конкретный проект или добавлять в модули то, чего там быть не должно. В результате получается, что полноценный тестовый комплект может быть очень велик, включая в себя mock object едва ли не для любой сущности системы. Я склоняюсь к тому, чтобы рано или поздно стиснуть зубы и сделать эту работу. Но пока никак :)
5) (ерунда, но всё-таки) GUI-инструменты так тестировать вообще не получается.

В результате мы сейчас пользуемся в основном «feature-тестами». Собираем всю систему — от начала до конца, вначале состоящую из заглушек. Потом даём ей простые задания (это ведь интеллект, он должен выполнять какую-то работу в каких-то условиях). Если очередной модуль, который мы пишем, справляется со своей работой, то задание выполняется. Если не выполняется, ищем ошибку в модуле.
И так раскручиваем систему. Для готовой системы у нас есть непростые «задачки», для которых известен вход и выход, а также понятно, какой модуль реально должен эту задачу решить. Стало быть, если не решает, мы можем предположить, где именно баг…

В общем, сложно это всё, но интересно :)
0
acerv #
Скажите, пожалуйста, где вы работаете — я с радостью пойду к вам в команду. Почему-то везде, где мне приходилось побывать, слова «тесты» «юнит-тестинг» и т.п. вызывали у менеджмента или непонимание, или полное отторжение, аля «мы не успеем сделать программу».
0
ApeCoder #
>>>К сожалению в статически типизированные языки не дружат с REPL

Не все. Хаскель и F#, например, дружат. У последнего в VS есть удобная консоль
0
ApeCoder #
У Сергея Зефирова была мысль, что динамическая типизация это вывод типов для бедных
0
shai_xylyd #
Спасибо, я теперь знаю как зовут автора одно из моих любимых блогов)
0
sfoid #
> Тест, который никогда не упал — бесполезный тест

Вы пытаетесь доказать это утверждение или это аксиома?

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

У вас две этих фразы ссылаются друг на друга. Вы утверждаете что
упавший тест не бесполезен предполагая что
тест не бесполезен и получаете то что тест должен падать.

«ты не прав, потом что предположем, что ты не прав, следовательно ты не прав» :-)

На самом деле мне кажется это можно доказать ссылаясь на старую истину (и принимая ее как аксиому), что невозможно написать программу без ошибок и следовательно, если тест не падает, значит он просто не обнаруживает ошибку в программе, которую тестирует.
0
shai_xylyd #
Да, не хорошо получилось.
Смысл в том, что если вы решили использовать тесты, то вы уже считаете, что тесты это хорошо и уже считаете их применение оправданным. Взявь эту уверенность за аксиому, получаем следствие про то, что тесты должны падать. В итоге можно читать следующем образом: если я верю в то, что тестирование приносит пользу, то я обязан верить и то, что тест, который никогда не завалиться — бесполезный тест. Надеюсь теперь стало яснее.

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