Каждая проверка называет своё слепое пятно
Большинство наборов тестов никогда не проговаривают, что означает зелёный прогон. Утверждения копятся, утверждения проходят, и это прохождение считают свидетельством чего-то, что никто не определял. А потом сквозь зелёный набор уезжает дефект, и на разборе выясняется, что нужного вопроса не задавал ни один тест — не потому, что его кто-то удалил, а потому, что никто и никогда не записывал, какие вопросы вообще задаются.
Ritmolux — музыкальный визуализатор, а это осложняет дело. На выходе картинка, а про картинку трудно что-либо утверждать: нет возвращаемого значения, которое можно сравнить, нет исключения, которое можно поймать, а те режимы отказа, которые волнуют людей, — выглядит мёртвым, выглядит как соседний, не реагирует на музыку — перцептивны. Пресет уходит в поставку, когда поведенческий набор зелёный, поэтому пришлось записать словами, свидетельством чего этот зелёный является.
Этот пост — о том, чего такая запись потребовала, а потребовала она заметно большего, чем я ожидал, открывая файл.
Пять проверок, и только одна из них что-то слышит
Пять проверок прогоняют весь поставляемый набор пресетов. Вот таблица из документа о тестировании, и важен в ней третий столбец:
| Проверка | Откуда берутся её числа | Заметит ли она пресет, который игнорирует музыку? |
|---|---|---|
reactivity | PCM через настоящий анализатор — четыре клипа (синус 60 Гц, аккорд в середине, тон 12 кГц, метроном 240 BPM), проталкиваемые по хопам | Да. Она единственная. |
sanity | один синтезированный кадр анализа | Нет — она спрашивает, освещён ли кадр, распределён ли он и есть ли в нём тональная структура |
animation | обнулённый кадр и полностью «раскачанный» для второго чтения — ни один не из PCM | Нет, и это осознанно |
distinctness | один синтезированный кадр, общий для семейства | Нет — она спрашивает, не похожи ли два пресета друг на друга |
golden | замороженные фикстуры с постоянными параметрами | Нет — она спрашивает, рисует ли рендерер то же, что рисовал раньше |
Четыре проверки из пяти не слышат ни одного сэмпла. Причина структурная, а не случайная, и картинка того, где каждая проверка врезается в конвейер, делает её очевидной:
flowchart TB pcm["четыре PCM-клипа<br/>синус 60 Гц · аккорд · тон 12 кГц · метроном 240 BPM"] an["Анализатор<br/>по хопам, с прогревом"] real["настоящий AnalysisFrame"] fake["синтезированный AnalysisFrame<br/>постоянный или обнулённый"] scene["выражения пресета → сцена → пиксели"] r["reactivity"] rest["sanity · animation · distinctness · golden"] pcm --> an --> real --> scene fake --> scene scene --> r scene --> rest
Отдельно стоит задержаться на строке animation. Она держит кадр анализа постоянным и спрашивает, движется ли картинка всё равно. Значит, пресет, у которого привязки заведены не на ту полосу — на верха там, где автор имел в виду низ, — всё равно анимируется по собственным часам сцены и всё равно проходит. Проверка не сломана. Она отвечает не на тот вопрос, который можно было бы вывести из её названия.
Документ прямо говорит, что это выбор, а не случайность:
Четыре из пяти правы, что синтезируют вход. Их вопросы — про кадр: освещён ли он, движется ли, отличается ли, совпадает ли со своим эталоном. Выдуманный кадр анализа отвечает на них верно и в несколько раз быстрее, чем проталкивание сэмплов. Перевод их на настоящий звук не купил бы ничего, а прогон стал бы кратно дороже. Это решение, а не упущение.
А абзацем ниже — ход, который я хочу выделить, потому что он появляется только в документе, который кто-то поддерживает, а не пишет однажды.
Аргумент про стоимость, которым изначально обосновали синтетический вход, со временем ослаб: более поздний план убрал из этой цифры прогревочные рендеры, так что перевести проверку на настоящий звук сейчас ощутимо дешевле, чем когда решение принимали. Документ фиксирует новое число — и отказывается пересматривать решение:
рассуждение выше не опирается на цену, поэтому решение остаётся в силе.
Это верное обращение с фактом, который подрывает вспомогательный аргумент, но оставляет нетронутым несущий. Несущим никогда не было «это слишком медленно»; несущим было «эти четыре проверки спрашивают про кадр, а синтетический кадр отвечает на это верно». Цена шла бонусом. Большинство кодовых баз, столкнувшись с новым измерением, сделали бы одно из двух: тихо его проигнорировали или рефлекторно развернули решение, раз одна из названных причин оказалась слабее, чем считалось. Записанное понимание того, какой именно аргумент нёс нагрузку, — вот что делает доступным третий вариант, и третий вариант здесь правильный.
На что даёт право зелёный прогон
Разобравшись, чего не видит каждая проверка, документ формулирует конъюнкцию:
Читайте зелёный набор так: рендерер выдал правдоподобный, отличимый, движущийся кадр, и пресет отзывается хотя бы на одну полосу настоящего звука.
Это заметно более скромное утверждение, чем «пресеты хорошие», и одно предложение стоит тех абзацев, которыми оно заработано: его можно приложить к тому, что вы думали про зелёный, и увидеть зазор. Дальше названы две вещи, лежащие за его пределами.
Первая — курирование. Зелёный «ничего не говорит о том, нужен ли библиотеке ещё один такой пресет; это кураторское суждение, принимаемое на закрытии плана, а не свойство, которое может удержать проверка». Граница между тем, что проверяет машина, и тем, для чего нужен человек, проведена заранее, а не обнаружена в тот момент, когда кто-то возразил против пресета, прошедшего все тесты. И спорить о том, должен ли был набор его поймать, уже не придётся.
Вторая острее, потому что это ограничение той единственной проверки, которая звук всё-таки слышит:
Чего зелёный по-прежнему не говорит — так это того, что пресет отзывается хорошо:
reactivityсравнивает раскачанную полосу с тишиной, а на фоне тишины привязка, которая уходит в насыщение чуть выше шумового порога, максимально отзывчива.
Вдумайтесь. Параметр, улетающий в максимум от любого звука, по той мере, которой пользуется reactivity, отзывчив идеально: раскачанное чтение отстоит от тихого настолько далеко, насколько это вообще возможно. И при этом он бесполезен — он одинаково реагирует на шёпот и на бочку, то есть реагирует не на музыку, а на факт наличия звука.
Проверка измеряет отличие от тишины, а отличие от тишины не отделяет отзывчивый параметр от насыщенного. Это разные вопросы, которые на одном краю диапазона дают одно и то же число. Пробел закрывает отдельная проверка — обход выражений на CPU по двенадцатисекундной пробе, которая ничего не рисует и потому почти ничего не стоит, — и существует она лишь потому, что кто-то записал, что́ reactivity сравнивает на самом деле, а не то, в честь чего она названа.
Результат, который хранят потому, что он отрицательный
Проверка animation спрашивает, меняется ли картинка между кадрами N и N+k. Есть класс фигур, для которых на этот вопрос вообще не ответит никакая статистика по изображению:
вращательно-симметричная фигура не может измерить собственное вращение: фигура, инвариантная относительно поворота на
2*pi/k, после такого поворота даёт идентичное изображение, поэтому разница кадров равна нулю при любом разрешении, и никакая статистика в области изображения этого не вытянет.
Это не проблема порога. Двенадцатиконечная звезда, повернувшаяся между двумя съёмками ровно на одну двенадцатую оборота, даёт те же пиксели, с которых начала. Поднимите разрешение — она даст те же пиксели точнее. Смените статистику — она по-прежнему увидит два одинаковых изображения. Настраивать нечего, потому что информации нет в изображениях: её уничтожила симметрия ещё до того, как что-либо измерили.
С этим сделали две вещи, и обе стоит перенять.
Исследование сохранили как запускаемый артефакт. Лестница разрешений, на которой всё установили, лежит в репозитории #[ignore]-тестом — «записанный отрицательный результат, и именно поэтому SIZE так и не менялся». Рано или поздно кто-нибудь увидит симметричный пресет, еле переползающий порог анимации, и задумается, не поможет ли съёмка в 1024 вместо 512. Ответ лежит в репозитории, он исполняемый и привязан к тому самому параметру, который объясняет. Сравните с обычной альтернативой: исследование было, вывод остался в чьей-то голове, и вопрос переоткрывают каждые полтора года.
Вывод вынесли из набора тестов в правила авторства. Такая фигура «*обязана двигаться радиально, и это ограничение для автора, а не дефект проверки*». Когда проверка чего-то не видит, вариантов ровно три: ослабить утверждение, ограничить вход так, чтобы невидимый случай не возникал, или сделать вид, что проверка это покрывает. Третий выпускает дефекты — и он же вариант по умолчанию, потому что не требует ни действий, ни писанины.
Мера, которая говорит за половину библиотеки — и сама об этом сообщает
Самая дисциплинированная запись во всём документе — про меру, применимую лишь к части кода, и начинается она именно с этого факта.
Доля геометрии в кадре измеряет, какая часть нарисованной длины линий попадает внутрь мирового прямоугольника цели рендеринга. Она существует потому, что очевидная альтернатива — покрытие по пикселям — не видит фигуру, у которой кончики уходят за кадр. Гребёнка сажает все зубцы на общую базовую линию, корона сажает все спицы в центр; обрезка кончиков стоит округления в числе освещённых пикселей. В результате два сильно переразмеренных пресета набрали больше, чем самый скромный законный контент, и никакой порог их не разделяет. Починка этих двух сдвигает меру геометрии на 0.4975 и 0.7788 — в девять и четырнадцать раз больше, чем те 0.055, которые были у покрытия по пикселям между самым скромным законным пресетом и правдоподобным порогом.
То есть для этого вопроса инструмент лучше. А дальше:
fragment_field,reaction_diffusion,attractor,swarmиemitterне строят списка сегментов, не покрыты вовсе и остаются на покрытии по пикселям. Разделение проходит по тому, растеризует ли сцена список сегментов, — а не по линии, которую предположил бы автор, — поэтому это проверка не уровня всего движка, и ни одно напечатанное ею число ничего не говорит про половину библиотеки.
Четыре семейства сцен внутри, пять снаружи. Граница — деталь реализации, то есть проходит не там, где её интуитивно провёл бы автор. Это ровно та ситуация, в которой число читают как покрывающее больше, чем оно покрывает, — поэтому документ говорит это вслух и жирным.
Дальше перечислены четыре вещи, которых мера не видит, и у каждой свой ответ:
Она измеряет длину, а не площадь. Толщина штриха не учитывается, поэтому волосяная линия и полоса в 24 пикселя той же длины весят одинаково, а толстый штрих, уходящий за кадр, недооценён относительно той картинки, которой он на самом деле стоит. Вердикт: «это правильная мера для вылета за кадр и плохая для чего угодно ещё; вариант со взвешиванием по толщине — другая мера с другим режимом отказа, и она намеренно не построена». Не недосмотр и не TODO — другой инструмент, рассмотренный и отклонённый.
Фигура, схлопнувшаяся в точку, получает идеальную 1.0. Сегменты нулевой длины не вносят вклада ни в одну из сумм, так что кривая, выродившаяся в точку, целиком в кадре — а больше этот инструмент ни о чём и не спрашивали. Нарисовано ли вообще что-нибудь — вопрос другой проверки, и эти две описаны как дополняющие друг друга, а не как ступени. Фигура, не рисующая ничего, сообщает не ноль, а отсутствие доли, — по той же причине: отсутствующее измерение и измерение, равное нулю, разные факты и не должны писаться одинаково.
Она не отличит намеренно приближенную фигуру от переразмеренной, потому что это одна и та же картинка. Здесь есть числа: пресет, приближающий намеренно, даёт 0.3492, вылезающий за кадр случайно — 0.3659, и они берут в вилку замороженный переразмеренный контроль с его 0.3563, один чуть ниже, другой чуть выше. Порог между ними поставить нельзя, потому что между ними ничего нет. Различие — в намерении, а намерения в кадре не бывает.
Третий пункт — общий случай результата про симметрию. Некоторые вопросы недоопределены не текущим измерением, а самим изображением, и никакая более удачная статистика этого не меняет.
Тот же файл: то ли зелёный, то ли осуждён — в зависимости от флага
Самая тревожная запись — про эмиттеры, пресеты, у которых популяция частиц набирается со временем, а не присутствует с первого кадра.
Каждая проверка снимает 30 кадров, полсекунды. Популяция эмиттера выходит на установившееся состояние за целое время жизни частицы, начиная с пустого пула. Значит, проверка, смотрящая на первые полсекунды, оценивает первые несколько процентов мира, а не сам мир. Флаг prewarm сдвигает этот разгон назад по времени, так что первый кадр — уже установившееся состояние.
Тот же черновик, больше ничего не менялось:
| Статистика | prewarm = 0 | prewarm = 1 | Порог |
|---|---|---|---|
sanity: покрытие / радиальные кольца | 0.0074, 0 из 10 — осуждён как пустой | 0.1470, 10 из 10 — структура на месте | 0.25 / 4 кольца |
animation: движение по следу фигуры | 0.0629 | 0.1702 | 0.01 |
reactivity: лучшая полоса | 0.0002 | 0.0195 | 0.02 |
Один флаг переводит пресет из «осуждён как пустой» в «структура на месте» сразу по трём независимым статистикам, и ни одна проверка не видит, по какую сторону этого флага она находится.
Сложность в том, что оба чтения законны. prewarm = 1 оценивает мир, который проектирует автор, — установившееся состояние, на которое он и будет смотреть через десять секунд после начала трека. prewarm = 0 оценивает то, как выглядят первые секунды живого сета, а это настоящий вопрос, и именно на него всё это время отвечало непрогретое число. Дефект не в том и не в другом. Дефектом было бы поверить, что одно число отвечает на оба.
Поэтому следствия выписаны в обе стороны, и вот это я перенёс бы дословно в любой проект: медленный эмиттер, проваливший sanity, «возможно, проваливает свой разгон, а не свой замысел — проверьте prewarm, прежде чем трогать внешний вид», а зелёная строка на прогретом мире «ничего не говорит о том, как выглядят первые секунды живого сета». Одно предложение защищает автора от ложного отрицания, другое — рецензента от ложного подтверждения.
А дальше — фраза, показывающая, что дисциплина устояла: «Ни длина съёмки, ни один порог не сдвинулись, чтобы это учесть.»
Очевидная починка была под рукой, и её отвергли. Снимать девяносто кадров вместо тридцати; или опустить порог sanity, пока непрогретый черновик его не переползёт. Любое из двух сняло бы сиюминутную проблему. Оба ухудшили бы проверку для всех остальных пресетов ради этого одного. Вместо этого взялись за сам разгон. Сдвиг порога ради того, чтобы прошёл конкретный вход, — это и есть способ, которым набор тестов перестаёт что-либо значить: не сразу, а по одному обоснованному исключению за раз, каждое из которых в свой день выглядело разумным.
Слепое пятно, порождённое тем, что делает эталон хорошим
Самая поучительная запись — про фоны, потому что слепое пятно там изготовлено решением, которое правильно.
Почти все эталонные снимки сняты с чёрным фоном. Для эталона это верный выбор: на чёрном каждый освещённый пиксель заведомо пришёл из сцены, а не из фона, поэтому диф эталона однозначен насчёт того, что изменилось. И это же, по словам документа, структурное слепое пятно:
на чёрном фоне правильно скомпоновать поверх фона и ошибочно его закрыть — это одна и та же картинка.
Стадия, неверно обращающаяся с альфой, на чёрном не стоит ничего. Она пробивает дыру в кадре в тот момент, когда пресет поднимает яркость фона, — а именно это и делает поставляемая библиотека.
Это слепое пятно породило четыре дефекта, и все — после одного решения, вынесшего фон из пост-цепочки и сделавшего альфу каждой стадии несущей: свёртка, уходящая в чёрное; рекомбинация bloom, загоняющая альфу выше единицы и вычитающая фон; и два разных шва отрисовки, каждый из которых выдавал постоянную альфу, равную единице, по всему своему квадрату. Каждый починили защитой одной и той же формы, и защиты сделаны пошовными, а не глобальными, потому что «ничто структурно не заставляет цвет и альфу шейдера идти в ногу».
Ответом было не переснять все эталоны на светлом фоне — это уничтожило бы то свойство, ради которого эталоны и нужны. Ответом стали ровно два светлых эталона, заведённых явно помеченными дополнительными фикстурами, а не строками основного реестра, — так что реестр по системам остаётся равномерно тёмным, и фраза о том, зачем нужен эталон дрейфа, остаётся верной.
А причина, по которой эти два нужны, острее, чем «покрыть светлый случай». Проход фона ленив: ниже видимого фона он вообще не строит свой градиентный конвейер. Значит, ни один тёмный эталон во всём наборе не исполняет ни строчки того кода. И вторая светлая фикстура не дублирует первую: она включает изогнутую полосу, которая при настройках первой остаётся невыбранной веткой select, так что первая не исполняет из неё ничего.
Это та же форма, что и синтетический вход у animation, только с другой стороны: тестовая конфигурация, верная для своей задачи и слепая по конкретной структурной причине, а починка — именованное исключение, а не изменение правила.
Ослабить проверку публично
В какой-то момент animation оказалась слишком строгой, и пресеты, которые действительно двигались, её проваливали. Починили ослаблением вердикта до дизъюнкции: пресет проходит по тихому чтению или по раскачанному.
Это настоящая потеря строгости, и репозиторий не даёт ей стать незаметной. Тест каждого пресета печатает, какая из веток вытянула его прохождение, и причина сформулирована одним придаточным:
потому что это ослабление реально, а свойство, которое не печатается, никто не перечитывает.
Ослабленная проверка, которая ничего не сообщает, снаружи выглядит ровно как строгая — и так навсегда. Ослабленная проверка, которая печатает, какая из двух веток спасла каждый пресет, поддаётся аудиту с одного взгляда: если всё стало проходить по раскачанной ветке, значит тихая перестала работать, и это видно любому, кто читает вывод, без специальных поисков.
Тот же инстинкт виден в том, как устроен статический контроль. В наборе постоянно живёт пресет, закреплённый как проваливающий обе ветки, — так что невырожденность самой проверки тоже под тестом. Проверка, которая незаметно начала пропускать всё, неотличима от работающей, если только что-то не обязано её проваливать.
Эталоны, и как не принять дефект обратно
Двадцать семь эталонных снимков фиксируют то, что рисует рендерер. Постоянный риск любого набора эталонов очевиден и универсален: когда вывод меняется законно, эталоны переснимают, и внутри этой пересъёмки может проехать дефект.
Защит две, и работают они по-разному.
Первая — порядок. Один тест утверждает относительное свойство: что стык в штрихе не является локальным минимумом яркости относительно внутренностей соседних сегментов, — то есть, без всяких порогов, что в линии нет дырки на углу. Это утверждение выполняется первым, даже под переменной окружения, которая принимает новые эталоны, — так что «зазубрину нельзя принять обратно».
Относительное утверждение старше изображения. Нельзя «принять» то, что сформулировано независимо от конкретной картинки, а значит, однажды найденный и починенный класс дефектов не может тихо вернуться через тот самый механизм, который создан для приёма законных изменений. Идея по-настоящему хорошая, и раньше я её не встречал: ставьте приём эталонов в очередь после ваших инвариантов.
Вторая — то, что произошло, когда все 27 законно сдвинулись разом после изменения в дизеринге при записи на дисплей. Их переприняли одним коммитом, и:
Пересъёмка ограничена, и это утверждали, а не принимали на веру.
Утверждение «изменение маленькое» — что каждый эталон сдвинулся максимум на уровень-другой и в ту сторону, которую предсказывает изменение дизеринга, — само проверили, вместо того чтобы поручиться за него в сообщении коммита. Это тот же инстинкт, что и у исполняемых проб при утверждениях бэклога из поста про документацию: отметка о том, что кто-то посмотрел, свидетельством не является — её нельзя перезапустить, и фиксирует она факт взгляда, а не то, что увидели.
Проза вокруг необычно пряма насчёт того, чего инструменты сделать не могут. «Прежде чем коммитить, посмотрите на перегенерированные PNG глазами» — первый эталон легче всего получить неверным, и никакая механика этого не убирает, потому что первый эталон по определению не с чем сравнивать. В другом месте, про предложение упростить четвёртую съёмку: «не «упрощайте» её». И фраза, которая могла бы стать эпиграфом ко всему документу:
Сужение оставляет тест зелёным и слепым.
Есть ещё одно признание, которое стоит привести, потому что это пробел, с которым проект решил жить, а не закрывать его. Headless-съёмки по построению закрепляют дешёвый уровень качества: дорогой проверяется точечно и ручным чек-листом на устройстве, а не общим прогоном. Документ называет это «настоящим пробелом в QA, названным, а не решённым». Это правильная формулировка для того, что вы решили не решать, — и она сильно лучше альтернативы, то есть молчания, которое читается как покрытие.
Что здесь общего
Ничто из этого не про графику.
Переносимая часть — одна привычка: для каждой проверки записывайте вопрос, который она на самом деле задаёт, и то, что остаётся незаданным. Не тот вопрос, в честь которого вы её назвали. Тот, который она буквально вычисляет.
Эта привычка даёт четыре вещи, которых набор тестов иначе не получает.
Формулировку того, что означает зелёный, в одно предложение, которую можно приложить к тому, что вы думали, будто он означает. В этом проекте два прочтения разошлись — и зазор между «пресеты хорошие» и «правдоподобный, отличимый, движущийся кадр, отзывающийся хотя бы на одну полосу» — это ровно то место, где живут дефекты.
Карту пробелов, чтобы они были известны, а не обнаруживались. Курирование не покрыто. Качество отклика не покрыто проверкой, которая отклик измеряет. Половина библиотеки лежит вне меры геометрии. Ни одно из этого не проблема; всё это стало бы проблемой, будь оно неожиданностью.
Отрицательные результаты как запускаемые артефакты, чтобы решённая невозможность не исследовалась заново. Отключённый тест, доказывающий, что разрешение не поможет, ничего не стоит в хранении и экономит следующему человеку день.
Чёткую границу между машиной и человеком. Посмотреть на первый эталон глазами. Решить, нужен ли библиотеке этот пресет. Проверить железо, которого нет в CI. Названные как человеческая работа, эти вещи перестают тихо считаться частью покрытия набора.
Цена реальна, и её стоит назвать прямо: документ о тестировании длинный, писать его стоило труда, и каждая проверка получила абзац про своё слепое пятно потому, что кто-то сел и разобрался, что именно сравнивает код. Это не бесплатно.
Взамен зелёный прогон делает конкретное ограниченное утверждение вместо расплывчато-успокоительного. А расплывчато-успокоительное хуже, чем никакого, — потому что на него полагаются.