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)
alexeyfv
18.04.2023 20:47Я бы ещё посоветовал почитать книгу "Принципы юнит-тестирования" (Владимир Хориков). Там все эти вопросы объясняются развёрнуто.
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; }
Neusser
18.04.2023 20:47Однако, ни отрицательные числа, ни большие числа, сумма которых выходит за пределы размера int, ни сложение с нулем здесь не проверены.
Отрицательные и сложение с 0 и нет необходимости проверять. Можно точно так же еще написать, что не проверено сложение с 5. Или 100. Или 99. Или двух одинаковых чисел.
nronnie
Со сложением пришла в голову такая мысль, что, на самом деле, операция сложения имеет вполне точное математическое определение: Отображение Z x Z -> Z которое симметрично, ассоциативно, и имеет "0" в качестве "нейтрального элемента" (∀ x ∈ Z: x + 0 = x), т.ч. м.б. в тестах именно это сначала и надо тестировать :))
iig
Но представления чисел в программе - это не совсем то, что подразумевается в школьной арифметике. int - как правило, кольца, float - непоймичто ;).