Привет! Представляю вашему вниманию перевод статьи Test Contra-variance
От переводчика: честно говоря, выбор слов ко-/контравариантность, по отношению к дизайну тестов, немного странен. Семантика конечно прослеживается, но весьма метафорична. Скорее всего, просто для красного словца и заголовка, привлекающего внимание, поэтому не сильно придирайтесь. В остальном — прекрасная заметка на тему TDD в формате диалога. Рассказано почему TDD это так больно, как сделать из юнит-тестов приятный инструмент и не относится к ним как к обязательно ломающемуся насилию над свободой самовыражения.
Ты пишешь юнит-тесты?
Конечно!
Сначала тесты, а потом код?
Да, я следую трем правилам TDD.
А есть ли разница в структуре модулей тестов и кода?
Я делаю один тестовый класс для каждого класса в коде.
То есть, если класс в основном коде называется User, то у тебя будет тестовый
класс с именем UserTest?
Да, почти всегда.
Получается что структура тестов ковариантна структуре кода?
Ну, полагаю что так.
Значит ты привязываешь структуру тестов к коду?
Никогда раньше не думал что это связанность, но, видимо, да.
И когда рефакторишь структуру классов кода, не трогая поведения, то
тесты ломаются?
Да, это правда.
Следовательно, ты не можешь запускать тесты во время рефакторинга?
Это почему?
Потому что рефакторинг это последовательность мелких правок, не ломающих
тесты.
Хорошо, исходя из определения, тогда это действительно не рефакторинг.
Вместо мелких правок ты вынужден делать одно большое изменение и надеяться
что потом все соберешь обратно, включая тесты.
Да-да, и что с того?
Это пример Проблемы Хрупких Тестов.
Проблема Хрупких Тестов?
Да, распространенная жалоба среди разработчиков, попробовавших TDD впервые.
Они замечают, что незначительные изменения в коде приводят к значительным
правкам в тестах.
Точно, сильно раздражает. Почти бросил TDD, когда впервые столкнулся с
проблемой.
К сожалению это обычная реакция.
И что делать?
Структура тестов должна быть контравариантна коду.
Контравариантна?
Да, структура тестов не должна отражать структуру кода. Из факта, что
какой-нибудь класс называется X, не должно следовать появление теста с
именем XTest.
Но постойте, это не по правилам!
Каким правилам?
Для каждого класса должен быть соответствующий тест.
Нет такого правила.
Как нет? Я точно о нем читал.
Не все что ты читаешь является правилом.
Ладно, если структура тестов должна быть контравариантной, то как ее такой сделать?
Давай сначала определимся с простым фактом. Если небольшое изменение в одном модуле
системы приводит к значительным изменениям в других компонентах, то у системы
плохой дизайн.
Это очевидно, software design 101 рассказывают о том же.
Следовательно, если небольшое изменение в коде приводит к большим изменениям
в тестах, то это тоже проблема дизайна.
Мысль понятна, согласен.
Поэтому у тестов должен быть свой дизайн. Он не может просто повторять
структуру основного кода.
Хм. То есть, если дизайны одинаковые, то они связаны, а связанность ведет
к хрупкости.
Именно. Связанность тестов и кода должна быть минимальной.
Стоп! Но тесты и код должны быть связаны, так как описывают одно и тоже
поведение.
Верно, их поведение связано, но это не означает связанность структуры кода
и тестов. И даже связанность поведения не должна быть настолько сильна, как ты
думаешь.
Можно пример?
Предположим я начинаю писать новый класс. Назовем его X. И я делаю новый тест
с именем XTest.
Но ты же только что сказал: "не надо так делать".
Не опережай события, мы только начали. По мере добавления новых тестов в XTest,
я добавляю новый код в X.
И рефакторишь код!
Естественно. Путем выделения приватных методов из оригинальных функций, которые
вызываются в XTest.
И ты рефакторишь тесты, правильно?
Точно! Я смотрю на связанность между XTest и X и работаю над ее минимизацией.
Это можно сделать через добавление параметров в конструктор X или повышение
уровня абстракции аргументов. Или даже введение полиморфического интерфейса
между XTest и X. (1)
И все это только для того чтобы написать тест?
Смотри на это с другой стороны. XTest это первый клиент X. Я всегда стремлюсь
уменьшить связанность между клиентом и сервером. Поэтому использую те же самые
техники, применимые для уменьшения связанности в обычном коде.
Хорошо, но структура тестов все равно повторяет структуру кода. X и XTest
никуда не делись.
Да, на уровне классов они одинаковы, но это еще изменится. Но заметь, что у
нас уже есть значительные отличия на уровне методов.
Действительно, XTest просто использует публичные методы X, а основная часть
кода в приватных методах, которые ты выделил.
Правильно! Структурная симметрия нарушена, но я собираюсь сломать еще больше.
Это как?
По мере того как я всматриваюсь в приватные методы, неизбежно начинаю видеть
как их можно сгруппировать в классы. Если группа методов использует
подмножество полей X, то ее можно выделить в отдельный класс. (2)
Но нового теста ты не пишешь?
Да! Все больше и больше функций будет выделено, все больше классов будет
обнаружено. И через некоторое время у нас будет целое семейство классов,
сидящее за простым API X.
И все они будут покрыты XTest.
Верно! Структура будет практически полностью независима. А еще API X постепенно
станет настолько чистым и абстрактным, что будет минимально связан
с клиентами, включая XTest.
Понятно. Похоже что структура тестов может развиваться независимо от
кода и я согласен что это хорошо. Но что насчет поведения, они до сих
пор сильно связаны по поведению.
Подумай что происходит в процессе разработки X. Как это отражается на XTest?
Ну, тестов будет все больше и больше, а интерфейс с X будет все чище и
абстрактнее.
Правильно, теперь повтори первую часть еще раз.
Тестов будет все больше и больше?
Да, и каждый из тестов предельно конкретный, являющийся небольшой спецификацией
определенного поведения. И в сумме они дадут…
Полные требования к поведению X API.
Именно! По мере продвижения разработки, набор тестов становится
спецификацией — тесты начинают быть все более определенными, конкретными.
Конечно, понимаю.
Но что случается с классами, спрятанными за X API? Что делает каждый хороший
дизайнер чтобы справится с растущим списком требований?
Чтобы справиться с обилием требований, естественно надо обобщать.
Правильно! Вместо того чтобы писать код для каждого отдельного случая, мы
его обобщаем.
И как это влияет на связанность поведения?
В процессе разработки поведение тестов становится все более определенным,
в то время как код становится все более общим, они движутся в противоположных
направлениях по оси общности.
И это уменьшает связанность?
Да, так как если код удовлетворяет требованиям, описанным в тестах, то
он также обладает способностью покрывать неописанные требования. (3)
И это очень важный момент. Потому что ни один набор тестов не может учесть
все варианты поведения. Код должен быть настолько общим, чтобы через покрытие
множества тестов начинать удовлетворять всем требованиям к системе.
Ты хочешь сказать, что тесты неполны?
Конечно! Это просто непрактично описывать абсолютно все. Так что произойдет,
если мы будем постепенно увеличивать общность кода, пока любые возможные
тесты не начнут проходить?
Ого! Мы продолжаем писать проваливающиеся тесты, увеличивающие
общность кода, до момента пока не сможем написать проваливающийся тест.
Ничего себе!
Вот тебе и ого. Еще на засыпку — процесс обобщения это процесс развязывания,
мы развязываем обобщая!
Невероятно! То есть мы развязываем и структуру, и поведение.
Правильно, можешь пересказать суть?
Хорошо, итак, структура тестов не должна отражать структуру кода, потому что
такая связанность делает систему хрупкой и затрудняет рефакторинг.
Другими словами, дизайн тестов не должен зависеть от кода, чтобы избежать связанности
с ним.
Хорошо, а что насчет поведения?
В то время как тесты становятся все более определенными, код будет все более
общим. Поведение тестов и кода движется в противоположных направлениях по
оси общности, пока мы больше не сможем написать новый проваливающийся тест.
Отлично, думаю ты все понял!
Вперед, к победе, с контравариантными тестами!
(1) Тут Дядю Боба немного занесло. Скорее всего он хотел рассказать про один из этапов написания тестов/кода — становление публичного интерфейса. В зависимости от опыта, разработчик может или сразу знать (спроектировать в голове) каким должен быть интерфейс, или это будет постепенное морфирование тестов и кода в нужную сторону. На этом этапе правки будут значительными с обеих сторон. Новички бросают TDD еще и потому, что пока не умеют делать хорошие интерфейсы и им приходится переписывать в два раза больше кода. Но это отличная практика! Двигает в сторону посидеть с блокнотом, порисовать и подумать.
(2) Ни в коем случае не руководство к действию! Это просто пример. Далеко не все классы можно разделить по этому признаку.
(3) Может показаться весьма спорным, но Дядя Боб предполагает, что мы уже больше не можем придумать теста или требования, которые сломали бы код. Код уже настолько общий, что учитывает множество неописанных деталей.
Автор: Антон Бобров