Статьи

Типичные ошибки, которые допускают младшие разработчики при написании юнит-тестов

Прошло 10 лет с тех пор, как я написал свой первый модульный тест. С тех пор я не могу вспомнить, сколько тысяч написанных мной модульных тестов. Если честно, я не делаю различий между исходным кодом и тестовым кодом. Для меня это одно и то же. Тестовый код является частью исходного кода. Последние 3-4 года я работал с несколькими командами разработчиков, и у меня была возможность просмотреть много тестового кода. В этом посте я кратко излагаю наиболее распространенные ошибки, которые обычно делают опытные разработчики при написании юнит-тестов.

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

public class RegistrationForm {
 
 private String name,email,pwd,pwdVerification;
 // Setters - Getters are ommitted 
 public boolean register(){
   validate();
   return doRegister();
 }
 
 private void validate () {
   check(name, "email");
   check(email, "email");
   check(pwd, "email");
   check(pwdVerification, "email");
 
   if (!email.contains("@")) {
     throw new ValidationException(name + " cannot be empty.");
   } 
   if ( !pwd.equals(pwdVerification))
     throw new ValidationException("Passwords do not match.");
   }
 
 private void check(String value, String name) throws ValidationException {
   if ( value == null) {
     throw new ValidationException(name + " cannot be empty.");
   }
   if (value.length() == 0) {
     throw new ValidationException(name + " is too short.");
   }
 }
 
 private boolean doRegister() {
   //Do something with the persistent context
   return true;
 }

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

@Test
 public void test_register(){
   RegistrationForm form = new RegistrationForm();
   form.setEmail("Al.Pacino@example.com");
   form.setName("Al Pacino");
   form.setPwd("GodFather");
   form.setPwdVerification("GodFather");
 
   assertNotNull(form.getEmail());
   assertNotNull(form.getName());
   assertNotNull(form.getPwd());
   assertNotNull(form.getPwdVerification());
 
   form.register();
 }
testing-darth-vader

Теперь этот тест, очевидно, пройдет, разработчик увидит зеленый свет, так что палец вверх! Давайте перейдем к следующему методу. Однако у этого тестового кода есть несколько важных проблем.

Первое, что, по моему скромному мнению, является самым большим злоупотреблением модульными тестами, — это то, что тестовый код не адекватно тестирует   метод регистров . На самом деле он тестирует только один из многих возможных путей. Мы уверены, что метод будет правильно обрабатывать нулевые аргументы? Как будет вести себя метод, если электронное письмо не содержит символ @ или пароли не совпадают?  Разработчики склонны писать модульные тесты только для успешных путей,  и мой опыт показал, что большинство ошибок, обнаруженных в коде, не связаны с успешными путями. Очень хорошее правило, которое нужно помнить, состоит в том, что для каждого метода требуется N чисел тестов, где N равно  цикломатической сложности  метода, добавляя цикломатическую сложность всех вызовов частного метода.

Далее идет название метода испытаний. В этом я частично обвиняю все эти современные IDE, которые автоматически генерируют глупые имена для методов тестирования, подобных приведенному в примере.  Метод испытания должен быть назван таким образом, чтобы объяснить читателю, что будет тестироваться и при каких условиях . Другими словами, он должен описывать тестируемый путь. В нашем случае лучшее имя может быть следующим: should_register_when_all_registration_data_are_valid.  В  этой статье  вы можете найти несколько подходов к именованию модульных тестов, но для меня шаблон «должен» наиболее близок к человеческим языкам и его легче понять при чтении тестового кода.

Теперь давайте посмотрим на содержание кода. Есть несколько утверждений, и это нарушает правило, согласно которому каждый метод тестирования должен утверждать одну и только одну вещь. В этом утверждается состояние четырех (4) атрибутов RegistrationForm. Это усложняет поддержание и чтение теста (о, да, тестовый код должен поддерживаться и читаться так же, как исходный код. Помните, что для меня нет различий между ними), и становится трудно понять, какая часть теста не пройдена.

Этот тестовый код также утверждает сеттеры / геттеры. Это действительно необходимо? В ответ на это я процитирую высказывание Роя Ошерова из его знаменитой книги: « Искусство модульного  тестирования »

Свойства (методы получения / установки в Java) являются хорошими примерами кода, который обычно не содержит никакой логики и не требует тестирования. Но будьте осторожны: как только вы добавите любую проверку внутри свойства, вы захотите убедиться, что логика проверяется.

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

Последняя, ​​но не менее важная проблема с тестовым кодом, который мы рассматриваем, заключается в том, что реальный тестируемый метод ( регистр ) никогда не утверждается. Он вызывается внутри тестового метода, но мы никогда не оцениваем его результат. Разновидность этого анти-паттерна еще хуже. Тестируемый метод даже не вызывается в тестовом примере. Так что просто имейте в виду, что вы должны не только вызывать тестируемый метод, но вы всегда должны утверждать ожидаемый результат, даже если это просто логическое значение. Кто-то может спросить: «А как насчет пустых методов?». Хороший вопрос, но это другое обсуждение — возможно, другой пост, но для того, чтобы дать вам пару советов, тестирование метода void может скрыть плохой дизайн, или это должно быть сделано с использованием инфраструктуры, которая проверяет вызовы методов (таких как  Mockito.Verify  ).

В качестве бонуса вот последнее правило, которое вы должны помнить. Представьте, что  doRegister  действительно реализован и выполняет некоторую реальную работу с внешней базой данных. Что произойдет, если какой-либо разработчик, у которого в локальной среде не установлена ​​база данных, попытается запустить тест. Верный! Все провалится. Убедитесь, что ваш тест будет иметь такое же поведение, даже если он запускается из самого удаленного терминала, который имеет доступ только к коду и JDK . Нет сети, нет сервисов, нет баз данных, нет файловой системы. Ничего такого!