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. Узнать подробнее о специализации.