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

Комментарии 39

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

Непонятная фраза. Тесты в контексте TDD/BDD — это средство фиксации поведения и детектирования его изменения. Ошибка означает, что или не зафиксировали требуемое поведение, или зафиксировали ошибочное.


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

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

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

В приниципе юнит-тесты могут служить инструментом моделирования. Если использовать TDD подход, то мы еще не знаем как будет реализован класс, какой шаблон проектирования мы задействуем и пр. но знаем чего от него хотим. Это его интерфейс. Под этот интерфейс мы пишем тест. Пишем мок имплементации для использования в тесте. А в тесте реализуем сценарии использования модуля. Таким образом можно избавться от ненужных вещей в модуле на этапе проектирования и сделать набросок модуляризации. Ведь когда мы пишем мок мы говорим себе например «вот тут я получаю данные от такой штуки которая может быть чем угодно, назовем ее DataProvider» — вот вам и интерфейс. А проектирование на интерфейсах — делает ваш код изначально менее зависимым от изменения его окружения.
Если бы мне пришлось проектировать какую нибудь архитектуру, пусть даже простую 3 ступенчатую, я бы не стал полагаться на то что могу мозгом обьять всю скрытую глубину задачи во всех ее проявлениях. Я бы сделал наброски применения этой архитектуры, только не в UML, а в коде, который можно гонять туда сюда и смотреть какие есть шороховатости.

Расскажу еще один пример из жизни. Я захотел, чтобы в наш инструментарий по UI тестированию прикрутили возможность генерации тестов на лету. Чтобы написал один тестовый шаблон, кинул в него данные, и меняешь только данные, а не копипастишь код. Такая фишка есть в том же junit, но в нашем инструменте ее нет. Ну так вот, чтобы разрабы смогли ее сделать я им предоставил набор тестов без использования этой функции и аналог с использованием. Т.е им нужно заставить работать мой код. Если все будет сделано правильно оба набора тестов будут выдавать идентичный результат.
В данном применении это вполне инструмент моделирования. Чем писать TLDR спецификации и долго мусолить на совещаниях, куда однозначней: «вот шаблон кода — заставь его работать».
… проще говоря, делая моделирование через тестирование, вы создаете симуляцию архитектуры.
Всё-таки моделирование заключается немного в другом. Грубо говоря, не в работе кода, а в его компетентности. Мне сложно судить по вашему примеру, но я приведу свой.

Какой-нибудь сервер может вполне шустро проходить любые тесты, но он свалится, когда, например, к нему пойдут запросы с секундным пингом, канал связи будет перегружен, запросы будут подделываться (ищем отель в Москве, а vpn показывает какой-нибудь Берлин) и т.п.
И такую ситуацию можно смоделировать в коде теста.
Сервер не является частью приложения, но частью системы. Как и операционная система, браузер и пр. Нужно четко обозначить границы системы. И в этом вам помогут тесты. Замокать браузер не получится. Это исполняющая среда (Runtime Environment). Но можно обеспечить корректную коммуникацию и реакцию на всевозможные параметры запросов, в точках раздела сред. Тестировать сервер на нагрузку нужно отдельно от приложения. Задержку ответа сервера можно тестировать в модели приложения. Это поможет вам в дальнейшем проще анализировать проблемы, когда ясно на стороне какой из компонент возникает проблема. Но это все сферическая теория в вакууме, на практике для такого подхода просто нет времени.
на практике для такого подхода просто нет времени

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

Мне казалось, что основная задача использования дублей — это сокращение «кода под тестами». Т.е. мы хотим проверить работу юнита(как бы масштабно мы его не определяли) — и мы не хотим зависеть от потенциальных проблем со стороны внешних зависимостей. Подключая сь к БД, даже если соединение быстрое и стабильное, мы увеличиваем количество мест, где может произойти ошибка, перестаем тестировать исключительно текущий модуль.
Стоит ли подключаться к БД, чтобы тестировать модуль, решает сам разработчик. Да, вы правы, мы получаем зависимость. Но как я писал выше, есть сторонники Sociable (общительных) тестов, которые строят архитектуру тестирования именно на зависимостях, и как они утверждают, найти такую ошибку не составляет труда. Это спорный момент, стоит ли делать зависимости или дубли, здесь каждый сам решает для себя, как ему удобнее, и главное, как удобнее для проекта.

По-моему, в любом случае черезчур общительные тесты (читай использующие внешние ресурсы) — это точно не юнит-тесты (если взаимодействие с ними не единственная отвественность модуля). Всегда понимал Sociable Unit Tests как тесты, которые не мокают/стабят код, который тестируется в соседнем тесте, код, который принадлежит к той же кодовой базе, что и тестируемый.

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

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


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


От лени всё это. "Да чего дублировать, и так же работает". Ну, сейчас работает. А когда через год или два администраторы базы устроят лёгкий рефакторинг и у вас UI сойдёт с ума на тестах, может уйти куча времени на разбор полётов и устранение проблем. И никто это не отловит заранее, потому что их тесты работают же, а ваши они не смотрят. ;)

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

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


  • написали (расширили базовым кейсом) end-to-end тест самого высокого уровня. Это что-то, что работает с реальным приложением — нажимая кнопки как реальный пользователь, работая с настоящей базой и т.п. и вообще работает с системой как "внешний" наблюдатель. Таких тестов мало, они медленные, и проверяют именно пользовательские сценарии
  • на уровне интеграционных тестов (видимо это и есть таинственные "общительные", я никогда такого термина не слышал) написали тест для публичного API. Тут уже какие-то внешние компоненты могут быть замоканы, может использоваться in-memory БД и т.п. Тем не менее, мы работаем с системой через внешний публичный API. Этих тестов уже больше, они относительно быстрые.
  • спустились на уровень юнит-тестов. Тут уже никаких вариантов с подключением к базе быть не может, если юнит-тест падает от того, что база недоступна — это очень плохой юнит-тест. Таких тестов может быть очень много, работают они быстро, и работают в изоляции — крайне редко нужно чтобы использовалась реальная внешняя зависимость для юнита.

Проблема "общительных" тестов в применении к юнит-тестам (сам термин не очень корректный и я не слышал, чтобы он вообще использовался) это отсутствие локализации ошибки. В идеале, по упавшему тесту должно быть возможно без всяких неоднозначностей определить, в каком компоненте (функции / классе / компоненте) ошибка. А с наличием зависимостей мы получаем неоднозначность — это наша system under test упала? Или ее зависимость?

Нет, «интеграционные» тесты не общительные, общительные тесты всё ещё юнит‐тесты, никакого (публичного) API не используется. Разница только и исключительно в том, что вы в юнит‐тестах заменили на дубли (mock, dummy, …): в «общительных» тестах вы меняете только лишь тяжёлые вещи (вроде того же подключения к БД). В одиноких вы меняете всё, что можете заменить без того, чтобы тест потерял всякий смысл.


А как проблема «общительных» решается уже писали:


  1. Вообще‐то юнит тесты быстрые, запускаются часто (зачастую до коммита вообще, если их можно запустить локально), обычно можно запускать конкретные тесты. Если вы поменяли какую‐то тестируемую систему и упал юнит‐тест, то искать «кто виноват» на уровне SUT не нужно независимо от того, какой тест упал: вы поменяли одну конкретную систему. А если вы поменяли сразу много и только потом пустили юнит тесты, то что‐то у вас с процессом разработки не так.
  2. Если тестируется и некая система, и её зависимости, то вы просто начинаете исправление с зависимостей, а потом перезапускаете тесты. Главное, чтобы тестировалось и то, и то.

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


Например, есть код типа


class Order 
{
  public function __construct(Customer $customer) {};
}

Если в тестах мы будем $customer мокать/стабить, то это будет "одинокий" тест, если просто подставим new Customer(), то это будет "общительный". При этом исходим из предположения, что когда дело доходит до тестов класса Order, то класс Customer уже протестирован, и падающие тесты Order будут означать, скорее всего, ошибку в Order.

Не эксперт в данном вопросе.
Таким образом, при использовании юнит тестирования скорость разработки существенно не уменьшается
Не понятно как пришли к такому выводу. Перечисленные пункты никакого отношения к выводу не имеют. Стоит ли читать дальше?
Имеется в виду «существенно не увеличивают время компиляции» что ли?
я хотел сказать, что юнит тестирование намного быстрее, чем другие виды тестирования. Главное, из-за времени выполнения самих тестов. А также, по причине того, что тесты пишут сами разработчики, а не третьи лица. По поводу скорости разработки, да скорость разработки, при написании тестов, уменьшается, но не существенно, при грамотном подходе и наличии опыта.
Классические юнит тесты (те что общительные) проверяли состояние (до/после), что зачастую требовало раскрытия внутреннего состояния юнита и нарушало инкапсуляцию.
Моки, на мой взгляд, стали революцией в юнит тестировании, так как позволили писать тесты только для публичного интерфейса юнита заменив проверку состояния на проверку поведения. Последняя предъявляет более высокие требования к дизайну делая код чище/лучше (можно получить код соответсвующий SOLID принципам ничего не зная о последних).
В TDD c изолированными (Solitary) тестами фаза рефакторинга практически отсутствует (если тесты и юниты достаточно простые) и заменяется проектированием. В классических тестах требования к дизайну минимальны (главное получить нужное состояние на выходе, что можно сделать бесчисленным количеством способов), и в фазе рефакторинга каждый улучшает код как может или не делает этого вовсе. Моки можно сделать строгими задавая ожидаемое поведение, любое неожиданное поведение (вызов не ожидаемого метода или метода с параметром отличным от ожидаемого) приведет к провалу теста. Тесты состояния менее чувствительны к сайд эффектам, так как зависимости могут быть неявными (DI — необязателен). Можно изменить поведение на некорректное таким образом, что тест состояния останется зеленым делая его практически бесполезным. Чтобы этого избежать тест должен запускаться на множестве входных данных, что делает его медленнее.

В связи со всем вышесказанным считаю классические юнит тесты устаревшим подходом, по которому, однако, написано очень много материалов (особенно от авторитетных светил вроде Дяди Боба), что вносит путаницу и уводит новичков от прогресса.
Хочу заметить еще один важный плюс изолированных тестов. В процессе разработки очень полезно использовать такую метрику как «покрытие кода». Проблема данной метрики в том, что она всегда врет. Если имеется 100% показатель покрытия кода, это всего лишь значит что мы прошлись по коду, но совершенно не значит что мы проверили его работоспособность. Имея неизолированный тест мы увеличиваем покрытие соседних юнитов, не проверяя их работоспособность. Наличие дублеров делает данную метрику чуть более «честной», так как мы вызываем только тестируемый код.
Наличие дублеров делает данную метрику чуть более «честной», так как мы вызываем только тестируемый код.
Как по мне то «Наличие дублеров делает данную метрику чуть более «ошибочной»», так как мы вызываем только код одного класса, а не ту связку классов которая будет в продакшине.
Предполагается что остальные классы тоже будут покрыты=).
То что один юнит будет вызывать другой юнит с заданными параметрами и нужное количество раз как раз таки удобно тестировать с помощью моков. Ну и интеграционные тесты никто не отменяет, просто не нужно оценивать покрытие на основе интеграционных тестов.
Например интеграционные тесты вызывают метод с параметрами:
(А=1, В=0) либо (А=0, В=1).
А юнит тест в придачу может вызывать случай (А=1, В =1), что в реальности не требуется, но делает покрытие 100%.
Вопрос в том, что такое 100% покрытие. Если одна и та же ветка кода вызывается два раза, это 200% покрытия? Что на самом деле нам говорит покрытие в 100%?

ЧТо каждая значимая строчка кода вызывалась минимум один раз во время прохождения тестов.

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

Мне лично, удобно использовать Solitary (одинокий) UnitTests для TDD, для быстроты разработки. Изолированность класса тут то что надо.
А для тестирования системы необходим инструмент который тыкает на кнопки вместо пользователя, ну или хотя бы дергает сервисы по сценариям пользователя.
Я к тому что процент покрытия тестами это на мой взгляд достаточно бесполезная метрика.
То есть если видишь что у тебя 70% покрытия тестами, то думаешь ну и фиг с ним. Будут баги, на багу сделаю интеграционный тест, если % покрытия при этом увеличится то и хорошо, а если нет то и пошел этот процент в Ж.

А что в проекте делают незначимые строчки кода? Ухудшают читаемость?


Мои наблюдения таковы, что в проекте, с которым я сейчас больше всего работаю, покрытие обычно отсутствует в коде обработки ошибок, дополнительно часть функциональности унаследована с тех времён, когда тестов ещё особо не писали. Как результат, я уже несколько раз наталкивался на ошибки в коде обработки ошибок, который я случайно или намеренно задействовал в интеграционных тестах других систем. Если бы не было таких «унаследованных» систем, то можно было бы реализовать требование «100 % покрытие», что автоматически означало бы «не забудьте тестировать обработку ошибок». Правда сама цифра мне как‐то без разницы, главное, чтобы обработку ошибок тестировали, это просто простейший вариант заставить контрибьюторов с излишне «позитивным» мышлением делать «негативные» тесты до review.

Если не ошибаюсь, Кнут шутил/утверждал что в любой программе, есть меньшая по объему корректная программа.

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

Мне довелось работать на медицинском проекте, где по стандартнам необходимо 100% тестирование.
Так вот, в основном это были юнит тесты, которые тестировали не только ту работу, которая необходима для устройства, но и какой то левый непонятный код.
Поскольку был не TDD, то есть не сначала тесты, а сначала несколько лет писалась логика, потом, перед сдачей, выделили время на покрытие тестами.
Как это делалось — бралась ветка if(condition){block1}else{block2} и под условия писался юнит тест.
Что тестировал этот тест?
Все что мог, в том числе излишнюю логику и даже замораживал, а не тестировал, код тех баг, которые неосознанно в код внес программист.
Это была заморозка логики, а вовсе никакой нифига не тест.
Поскольку программы пишутся не минимальные то и 100% покрытия это абсурд.
Это была заморозка логики, а вовсе никакой нифига не тест.

Это и есть основное назначение юнит-тестов :) При практически идеальном покрытии кода этими тестами они упадут при малейшей попытке изменить логику. Они являются красным индикатором "ЛОГИКА ИЗМЕНЕНА!!!". А желательное это изменение ил инет — решать разработчику. В перовом случае править тесты, во втором — код.

Так для недопущения таких ситуаций reviewer’ы потом смотрят и на код, и на тесты и спрашивают «а чего это ваш код ведёт себя именно так?» в особых случаях. С полным покрытием такие «особые случаи» можно легче заметить по тестам, без полного покрытия их и не заметит никто. Если нет людей, которые проверяют код, то тесты в любом случае не сильно помогут. Только стремиться к 100 % покрытию функциональными, а не юнит, тестами с этой точки зрения имеет наибольший смысл: видно, что код реально может быть вызван, а понять, адекватно ли поведение, легче.

стремиться к 100 % покрытию функциональными, а не юнит, тестами
Юнит тесты, тоже функциональные. Вы наверно имеете в виду интеграционные тесты и выше — System и Acceptance

Эм, приёмочные тесты и есть функциональные, сравните определение в https://en.wikipedia.org/wiki/Functional_testing («a type of black-box testing that bases its test cases on the specifications of the software component under test») и https://en.wikipedia.org/wiki/Acceptance_testing («a test conducted to determine if the requirements of a specification or contract are met»). В https://ru.wikipedia.org/wiki/Разработка_через_тестирование вообще прямо написано


Приёмочные (функциональные) тесты (англ. customer tests, acceptance tests) — тесты, проверяющие функциональность приложения на соответствие требованиям заказчика.

Не учитываются в покрытии строки комментариев, строки деклараций и т. п. "Значимая" тут -"исполняемая".


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


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

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

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


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

Фиксация желаемого поведения заключается в коде теста. Если вы ошиблись в коде теста, значит вы ошиблись в выражении того, какое поведение вы считаете желаемым. Как говорится "телепаты в отпуске", а "написанное пером не вырубишь топором" :)

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

То, что я видел — по строкам. Ещё наши assert(false) за код принимают :) (Хотя сами виноваты, нужно компилировать с -DNDEBUG, иначе там реально будет код.)

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории