Ложно-положительные unit тесты

Этот пост продолжает цикл этапов написания unit test’а. Он является полностью независимым и не требует прочтения предыдущих. В примерах используется NUnit и FluentAssertions.

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

Возникает резонный вопрос, какой вообще смысл в таком тесте? Честно говоря, никакого. Казалось бы, о чём мы вообще тут говорим? Ведь всё просто, достаточно не писать ложно-положительные тесты, т.к. в них нет смысла. Проблема в том, что часто по виду теста нельзя понять, является ли он ложно-положительным. Рассмотрим на примере.

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

Вот наш unit under test, точнее важная для нас его часть:

public class Portal
{
    public string Inscription { get; private set; } = "Thou shalt not talk!";

    public void Deactivate()
    {
        this.Inscription = null;
    }
}

А вот unit test, который проверяет эту логику:

[Test]
public void WhenDeactivatingPortal_ThenInscriptionShouldDisappear()
{
    // arrange
    var portal = new Portal();

    // act
    portal.Deactivate();

    // assert
    portal.Inscription.IsNullOrEmpty();
}

На первый взгляд всё в порядке. Тест зелёный, сдан в репозиторий и успешно проходит во время continuous integration билда. Прошло время, и в один солнечный день некий разработчик решает, что выключенный портал без надписи это слишком скучно, и меняет код на своё усмотрение:

public class Portal
{
    public string Inscription { get; private set; } = "Thou shalt not talk!";

    public void Deactivate()
    {
        this.Inscription = "Under maintenance";
    }
}

Разработчик не подумал о том, что на выключенный портал код отрисовки надписи не рассчитан и приложение бы в этом месте упало. Однако же у нас есть unit test, который должен был упасть на билд сервере и не дать пролезть багу в систему. Однако этого не произошло. Я думаю, что если тест был бы написан не на C#, а на японском, он бы покончил с собой, потому что в ключевой момент не исполнил цель всей своей жизни.

Давайте разберёмся, почему так произошло. Всё дело в последнем методе IsNullOrEmpty(). Это обычный extension метод, который делает вызов метода string.IsNullOrEmpty() чуть более удобным. В случае отрицательного результата он возвращает false, который просто игнорируется тестом.

public static class Extensions
{
    public bool IsNullOrEmpty(this string value)
    {
        return string.IsNullOrEmpty(value);
    }
}

Видимо кто-то, кто написал тест, банально перепутал этот метод с вызовом реальной проверки из assertion framework’а. Правильная проверка должна выглядеть так:

[Test]
public void WhenDeactivatingPortal_ThenInscriptionShouldDisappear()
{
    // arrange
    var portal = new Portal();

    // act
    portal.Deactivate();

    // assert
    portal.Inscription.Should().BeNullOrEmpty();
}

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

Алгоритм такой проверки прост. Достаточно пойти в реализацию тестируемого метода и изменить её так, чтобы проверяемая логика стала работать наоборот или перестала работать вообще.

В нашем примере достаточно убрать код, который стирает надпись.

public class Portal
{
    public string Inscription { get; private set; } = "Thou shalt not talk!";

    public void Deactivate()
    {
    }
}

Если тест упал, то теперь надо убедиться, что он упал с ожидаемым сообщением. Если так и есть, то проверка на ложно-положительность пройдена успешно. Если тест не упал или упал с неправильным сообщением, например с “Object reference not set to an instance of an object”, то тест проверку не прошёл и надо его исправлять. Чаще всего такие исправления сосредоточены в блоке Assert.

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

public class Portal
{
    public string Inscription { get; private set; } = "Thou shalt not talk!";

    public void Deactivate()
    {
        this.Inscription = null;
    }
}

Кстати, не бойтесь что вы забудете поменять код назад. У вас будет красный тест, который упадёт и напомнит 🙂

В следующем посте мы поговорим о том, как проверить тест на ложно-отрицательность.

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s