Этот пост продолжает цикл этапов написания unit test’а. Он является полностью независимым и не требует прочтения предыдущих, но я рекомендую почитать хотя бы про ложно-положительные тесты. В примерах используется NUnit и FluentAssertions.
Про ложно-отрицательные тесты можно говорить много. В этой статье мы сфокусируемся на том, как в процессе написания теста убедиться, что он не будет ложно-отрицательным.
Дадим определение. Ложно-отрицательным является тест, который падает, но при этом логика, которую тест должен проверять, работает правильно. Грубо говоря такой тест – это как мальчик в сказке, который кричал “Волки!” в шутку, и ему в конце концов перестали верить.
В шутку тесты мы не пишем, но ложно-отрицательные тесты всё равно могут получаться. Я покажу это на примере.
Тестируем портал между измерениями. По логике приложения портал не пропускает мёртвых героев. В случае, когда мёртвый герой пытается пройти сквозь портал, вылетает exception с сообщением “Hero dead”. Ниже приведён сам unit under test.
public class Portal { public void Teleport(IHero hero) { if (!this.IsActivated) throw new Exception("Portal is not activated"); if (hero.IsDead) throw new Exception("Hero dead"); } }
Как мы видим, если портал не был активирован, тоже вылетает exception. Получается, что в тесте проверки только на наличие exception будет недостаточно. Потому что мы хотим убедиться, что сработала именно логика, проверяющая мёртвого героя. Ниже приведён тест, проверяющий эту логику.
public void WhenTeleportingHero_AndHeroIsDead_ThenShouldThrowHeroDead() { // arrange var hero = new Hero { IsDead = true }; var portal = new Portal(); // act var exception = CatchException(() => portal.Teleport(hero)); // assert exception?.Message.Should().Be("Hero dead"); }
Здесь CatchException – это самописный метод, который возвращает вылетевший exception или null, если всё прошло без ошибок.
Этот тест прошёл все этапы написания unit test’а, кроме проверки на ложно-отрицательность. Программист, который его написал, проделал аккуратную работу, закоммитил код, убедился, что continuous integration билд прошёл успешно, и, довольный собой, занялся следующей задачей.
Дни сменялись ночами, с деревьев опала листва, выпал снег и, когда прорезались подснежники, за код портала взялся другой сотрудник. Одной из его задач было добавить новый функционал. А заодно он увидел, что “Hero dead” написано неправильно с точки зрения правил английского языка. И исправил это.
public class Portal { public void Teleport(IHero hero) { if (!this.IsActivated) throw new Exception("Portal is not activated"); if (hero.IsDead) throw new Exception("Hero is dead"); } }
В результате на билд сервере упал тест и разработчик укрепился во мнении, что юнит тестирование мешает делать программу лучше. Данный тест упал неправильно, ведь логика приложения нарушена не была. Так вышло потому, что тест завязался на всё сообщение, а не только на важную для проверки логики часть. В данном случае важным является факт смерти, а также сам герой, т.к. мертвым мог бы быть не только он, а, например, имп дыры между мирами. Тест, который проверяет только эти факты выглядит так:
public void WhenTeleportingHero_AndHeroIsDead_ThenShouldThrowHeroDead() { // arrange var hero = new Hero { IsDead = true }; var portal = new Portal(); // act var exception = CatchException(() => portal.Teleport(hero)); // assert exception?.Message.ToLower().Should().Contain("hero", "dead"); }
Такой тест не упадёт в новой версии кода. И даже при более интересном сообщении вроде “Dead heroes are not allowed to go through portal”. Также обратите внимание, что проверка регистронезависимая.
Таким образом, после имплементации теста, для недопущения ложных падений, его надо проверить на ложно-отрицательность. К сожалению, в проверке на ложно-отрицательность нет готового алгоритма, как был в проверке на ложно-положительность. Тут придётся немного подумать 🙂
Надо посмотреть очень внимательно на тест и спросить себя, кажется ли он хрупким? И если да, то в каких местах? Когда такие места обнаружены, надо придумать как их исправить. Я постараюсь привести пример, и на нём показать, в каком направлении можно думать.
Например, представим себе, что если герой достиг максимального уровня, то мы считаем его просветлённым, поэтому при проходе между мирами его здоровье восстанавливается на максимум. В остальных случаях портал просто накидывает очки опыта.
Тест на восстановление здоровья после шага имплементации будет выглядеть так:
public void WhenTeleportingHero_AndHeroHasMaxLevel_AndHeroHasHalfHP_ThenHeroHPShouldRestoreToFull() { // arrange var hero = new Hero { Level = 80, HealthPercent = 50 }; var portal = new Portal(); // act portal.Teleport(hero); // assert hero.HealthPercent.Should().Be(100); }
Попробуем поверить его на ложно-отрицательность и посмотрим в каких местах тест является хрупким. Я рекомендую обращать внимание на магические константы. В нашем случае это 80, 50 и 100. 50 соответствует половине HP в процентах и с этим всё в порядке. Со значением 100 тоже всё хорошо, т.к. 100% это и есть максимум здоровья. А вот 80 мы приняли за максимальный уровень, что не всегда может быть таковым. Если максимальный уровень станет 90, то мы получим ложно-отрицательное срабатывание этого теста. Давайте это исправим.
Если у нас в коде где-то хранится максимальный уровень героя, то хорошо использовать его. В таком случае надо тест станет приблизительно вот таким:
public void WhenTeleportingHero_AndHeroHasMaxLevel_AndHeroHasHalfHP_ThenHeroHPShouldRestoreToFull() { // arrange var hero = new Hero { Level = Hero.MaxLevel, HealthPercent = 50 }; var portal = new Portal(); // act portal.Teleport(hero); // assert hero.HealthPercent.Should().Be(100); }
Но что делать, если максимальный уровень героя не записан в виде константы в коде, а задаётся, например, внешними правилами? Можно добавить в тест вызов подсистемы этих внешних правил, но это сделает тест ещё более хрупким, т.к. при багах в этой подсистеме он тоже начнёт падать.
Для того, чтобы понять, как поступить, проведём мысленный эксперимент. Чем плоха эта хрупкость теста? Допустим у нас есть 22 теста, которые так или иначе завязаны на то, что максимальный уровень героя – 80. Как только правила игры изменятся и максимальный уровень станет 90, то всё 22 теста станут красными и тому, кто имплементирует повышение героя, придётся тратить время на каждый тест, чтобы понять что он всего лишь ложно-отрицательный.
Это время можно сократить, если написать тесты таким образом, чтобы при исправлении одного такого ложно-отрицательного теста, всё остальные тоже исправились. Для этого можно завести константу в сборке тестов и использовать её. Наш тест станет выглядеть вот так:
public void WhenTeleportingHero_AndHeroHasMaxLevel_AndHeroHasHalfHP_ThenHeroHPShouldRestoreToFull() { // arrange var hero = new Hero { Level = Balance.MaxHeroLevel, HealthPercent = 50 }; var portal = new Portal(); // act portal.Teleport(hero); // assert hero.HealthPercent.Should().Be(100); }
Здесь Balance – это класс, который лежит в сборке с тестами и содержит константы, которые зависят от игрового баланса. В идеале такого рода классов должно быть немного и они должны лежать в корне сборки, чтобы их легко было найти и повторно использовать в других тестах.
Теперь в гипотетическом будущем после исправления первого упавшего ложно-отрицательного теста, все остальные исправятся автоматически. Данный пример хорошо показывает, что ложно-отрицательность – это очень тонкая материя и идеал не всегда возможен.
Подведём итоги. Цель проверки на ложно-отрицательность – гарантировать отсутствие ложных падений в случае, если тестируемая логика не менялась. Если гарантировать отсутствие ложных срабатываний не возможно, то надо минимизировать вред от этих таких тестов. Идеальное минимизирование – когда после исправления одного теста все остальные исправляются автоматически без их открытия и редактирования. Простого алгоритма проверки нет, требуется привлечение умственных ресурсов разработчика, а именно анализ теста на “хрупкость”.
В следующем посте мы поговорим о рефакторинге теста перед отправкой его во взрослую жизнь 🙂