1. Недостаточное покрытие

Иногда из-за нашей лени, невнимательности или чего-либо ещё получается неполный охват тестами всех важных сценариев, крайних случаев и потенциальных ошибок. Представим, что у нас есть очень простой класс Calculator, который умеет делать сложение:

public int Add(int a, int b)
{
    return a + b;
}

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

[Test]
public void Add_WhenCalled_ReturnsSum()
{
    // Arrange
    var a = 40;
    var b = 2;
    var calculator = new Calculator();

    // Act
    var result = calculator.Add(a,b);

    // Assert
    Assert.AreEqual(42, result);
}

Тестирует ли код выше метод Add? Да, тестирует и даже гарантирует правильность сложения чисел 40 и 2. Однако, ни отрицательные числа, ни большие числа, сумма которых выходит за пределы размера int, ни сложение с нулем здесь не проверены.

Как сделать правильно?

Ещё до написания кода желательно вместе с аналитиком/тестировщиком/другими разработчиками обсудить все возможные корнер-кейсы (крайние случаи) и то, как код должен реагировать при встрече с ними.

2. Переизбыток тестов

В противоположность к первому пункту можно увлечься и насоздавать много излишних тестов для тривиального или простого кода, что приведет к увеличению нагрузки на поддержку и замедлению выполнения тестов.

[Test]
public void Add_WithFortyAndTwo_ReturnsFortyTwo()
{
    // ...
}

[Test]
public void Add_WithOneAndOne_ReturnsTwo()
{
    // ...
}

[Test]
public void Add_WithTenAndTen_ReturnsTwenty()
{
    // ...
}

[Test]
public void Add_WithZeroFirst_ReturnsSum()
{
    // ...
}

[Test]
public void Add_WithZeroSecond_ReturnsSum()
{
    // ...
}

Как сделать правильно?

Совет по составлению списка проверок с другими людьми здесь так же актуален. Плюс, порой можно воспользоваться передачей параметров в тестовый метод, что сокращает размер файла с тестами и их чтение. Еще более продвинутый вариант - использовать генераторы данных, например, Bogus. Ну и самый хардкорный вариант - использование pairwise testing.

3. Нетестируемый код

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

public class Calculator
{
    private readonly ILogger _logger;

    public Calculator()
    {
        _logger = new Logger();
    }

    public int Add(int a, int b)
    {
        _logger.Log("Add method called.");
        return a + b;
    }
}

Ну и соответственно, чтобы сделать его тестопригодным, надо сделать инверсию зависимости, а если по-русски, то заменить зависимость от класса на зависимость от интерфейса:

public class Calculator
{
    private readonly ILogger _logger;

    public Calculator(ILogger logger)
    {
        _logger = logger;
    }

    public int Add(int a, int b)
    {
        _logger.Log("Add method called.");
        return a + b;
    }
}

Как сделать правильно?

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

4. Игнорирование или пропуск тестов

Если бы сам не столкнулся с подобным в нескольких компаниях, то мне бы и в голову не пришло, что тесты можно (а порой даже нужно) игнорировать.

Как сделать правильно?

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

5. Тестирование реализации

Довольно стандартная ловушка для новичков в юнит-тестировании - написать сначала нужный код и потом на основе этого кода писать тесты. Причем тесты пишутся так, чтобы покрыть логику этого написанного кода. Проблема такого подхода - то, что не было написано, не будет и протестировано. Например, если при добавлении в класс Calculator метода Divide мы не учтем в коде проверку деления на ноль, то и при написании тестов по уже существующему коду вероятность написать тест деления на ноль исчезающе мала.

Как сделать правильно?

Вспоминаем совет к первым двум пунктам - составлять тест-кейсы ДО написания кода и опираться в них на требуемую бизнес-логику, а не на уже реализованную функциональность. Хотя, сразу оговорюсь, что для написания тестов к уже существующему коду, по которому никаких зафиксированных бизнес-требований нет и никто их не знает/не помнит, подход на основе реализации вполне подходит. Но только для целей регрессионного тестирования.

6. Хрупкие тесты

Предположим, что мы написали класс А, который реализует необходимую нам бизнес-логику. Затем, в процессе рефакторинга, мы вынесли из класса А два вспомогательных класса - В и С. Нужно ли писать тесты на все три класса? Конечно, это зависит от логики, которая была вынесена во вспомогательные классы и того, будет ли она использоваться где-то ещё помимо класса А, однако, в 99% случаев писать тесты на классы В и С не нужно.

Как сделать правильно?

Рискуя набить оскомину, повторюсь, надо тестировать не написанный код, а бизнес-логику, которая реализуется этим кодом.

7. Отсутствие организации тестов

Видел я и такие проекты, гды пытались внедрить юнит-тесты, однако, все они лежали в корне тестового проекта и было тяжело разобраться есть ли уже нужные тебе тесты или нет. И вместо того, чтобы разбираться в этом бардаке и искать нужный класс, люди просто создавали ещё один, куда писали свои тесты. Хаос в таком случае только увеличивался.

Как сделать правильно?

Надо договориться о том, как будут организованы тесты в вашей компании/команде. Один из наиболее простых и распостраненных подходов - полностью копировать структуру основного проекта, добавляя постфикс "Tests". То есть, если был проект CalculationSolution и в нем был путь Calculations/Calculators/, по которому лежал файл Calculator.cs, для которого мы хотим добавить юнит-тесты, то юнит-тесты должны быть в проекте CalculationSolution.Tests по пути Calculations/Calculators/CalculatorTests.cs.

8. Божественные тесты

Как в процессе программирования может появиться god object - класс, который делает все и вся, так и при написании тестов могут получаться тесты, в которых проверяется не что-то одно, а сразу штук 10 разных аспектов. Да, такая "денормализация" тестов порой имеет место быть в end-to-end, UI или интеграционных тестах в целях экономии ресурсов (в т.ч. времени выполнения), однако, юнит-тесты должны проходить очень быстро и нет смысла усложнять себе разбор упавших тестов ради экономии пары миллисекунд.

Как сделать правильно?

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

9. Недостаточная обработка ошибок

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

Как сделать правильно?

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

10. Смешивание юнит-тестов с другими видами тестов

Как я писал выше, юнит-тесты обычно проходят очень быстро, так как не требуют сложной подготовки, подтягивания зависимостей и прочего. Остальные виды тестов уже несколько более продвинутые и более ресурсоемкие. Поэтому смешивание всех тестов в одну кучу является не очень хорошим вариантом.

Как сделать правильно?

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

Статья подготовлена в рамках набора на специализацию C# Developer. Узнать подробнее о специализации.

Комментарии (5)


  1. nronnie
    18.04.2023 20:47
    +1

    Со сложением пришла в голову такая мысль, что, на самом деле, операция сложения имеет вполне точное математическое определение: Отображение Z x Z -> Z которое симметрично, ассоциативно, и имеет "0" в качестве "нейтрального элемента" (∀ x ∈ Z: x + 0 = x), т.ч. м.б. в тестах именно это сначала и надо тестировать :))


    1. iig
      18.04.2023 20:47

      Но представления чисел в программе - это не совсем то, что подразумевается в школьной арифметике. int - как правило, кольца, float - непоймичто ;).


  1. alexeyfv
    18.04.2023 20:47

    Я бы ещё посоветовал почитать книгу "Принципы юнит-тестирования" (Владимир Хориков). Там все эти вопросы объясняются развёрнуто.


  1. klopp_spb
    18.04.2023 20:47

    ни отрицательные числа, ни большие числа, сумма которых выходит за пределы размера int, ни сложение с нулем здесь не проверены.

    О, да... Кусочек из старого моего:

    Hidden text
    #include <stdio.h>
    #include <errno.h>
    #include <inttypes.h>
    
    static int test( const char *in, int base, intmax_t want )
    {
        intmax_t rc = strtoimax( in, NULL, base );
    
        if( rc != want ) {
            fprintf( stderr, "Error in \"%s\": expect %lld, got %lld.\n", in, want, rc );
            return 1;
        }
        return 0;
    }
    
    int main()
    {
        int errors = 0;
    
        errors += test( " -123junk", 10, -123 ); /* explicit base 10           */
        errors += test( "11111111", 2, 255 );    /* explicit base 2            */
        errors += test( "XyZ", 36, 44027 );      /* explicit base 36           */
        errors += test( "010", 0, 8 );           /* octal auto-detection       */
        errors += test( "10", 0, 10 );           /* decimal auto-detection     */
        errors += test( "0x10", 0, 16 );         /* hexadecimal auto-detection */
    
        /* overflow, must set errno */
        errno = 0;
        strtoimax( "9223372036854775808", NULL, 10 );
        if( errno != ERANGE ) {
            fprintf( stderr, "Overflow test failed.\n" );
            ++errors;
        }
    
        /* invalid base, must return 0 */
        if( strtoimax( "10", NULL, 44 ) != 0 ) {
            fprintf( stderr, "Invalid base test failed.\n" );
            ++errors;
        }
    
        /* base and input mismatch, must return 0 */
        if( strtoimax( "333", NULL, 2 ) != 0 ) {
            fprintf( stderr, "Base and input mismatch test failed.\n" );
            ++errors;
        }
    
        if( errors ) {
            fprintf( stderr, "%d tests failed!\n", errors );
        }
        else {
            puts( "All tests passed correctly." );
        }
        return errors;
    }
    


  1. Neusser
    18.04.2023 20:47

    Однако, ни отрицательные числа, ни большие числа, сумма которых выходит за пределы размера int, ни сложение с нулем здесь не проверены.

    Отрицательные и сложение с 0 и нет необходимости проверять. Можно точно так же еще написать, что не проверено сложение с 5. Или 100. Или 99. Или двух одинаковых чисел.