Используйте модульные тесты для информирования дизайна кода

Эта статья изначально была опубликована на моем сайте — https://kislayverma.com/programming/more-than-testing-writing-unit-tests-for-better-design/

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

Первые почти десять лет своей карьеры программиста я почти не писал модульных тестов. Я написал много кода и протестировал почти весь его, запустив и проверив сквозное поведение, в основном вручную. Однако по мере того, как я наследовал все более и более крупные кодовые базы и проекты в нескольких командах в качестве архитектора, я начал понимать, почему все продолжали модульное тестирование. Обычно нет более быстрого способа узнать, что код, который вы пишете, безопасен и работает должным образом. Это огромная подстраховка при рефакторинге сложного кода. Это, безусловно, дешевле, чем запускать службу с ее хранилищем данных и другими вспомогательными принадлежностями, просто чтобы посмотреть, подходит ли небольшое изменение. Хорошее покрытие юнит-тестами также служит хорошим и быстрым способом убедиться, что вы поставляете то, что нужно в мире CI-CD и т. д. Выполнение интеграционных тестов, требующих тщательной настройки, если вы хотите развертывать очень часто, может быть очень дорогим. на инфра.

Однако после регулярного написания модульных тестов в течение нескольких лет я понял, что в них есть гораздо более глубокая ценность. Модульные тесты (с имитацией и т. д.) не просто проверяют код, они в первую очередь удостоверяются, что код пригоден для тестирования. Процесс написания тестов помогает улучшить дизайн кода в процессе написания. С тех пор, как я понял это, я стал намного более строгим в написании тестов — не потому, что я очень забочусь о тестировании как таковом, а потому, что их написание делает мой код лучше спроектированным, более модульным и выявляет скрытые границы в нем.

Тестировать — значит проводить границы

Давайте рассмотрим, что мы делаем, когда пишем тесты. Мы вводим некоторые данные для фрагмента кода (куча классов, классов, методов) и проверяем, что результат соответствует нашим ожиданиям. Эта идея ввода и вывода неявно определяет границы — границу извне, которую мы подаем на вход, и границу, за пределами которой мы получаем и проверяем вывод. Внутри этой границы может быть много путей кода, и мы хотим проверить все пути в нашем тестировании.

Определение ожидаемого результата зависит от выбранной нами границы. API create в стиле REST можно протестировать несколькими способами. Если мы решим включить базу данных в границы, ожидаемым результатом будет запись в базе данных (для допустимого ввода). Если мы нарисуем границу непосредственно перед уровнем постоянства, ожидаемый результат может быть допустимым объектом для вставки или отсутствием исключения или чем-то в этом роде. Таким образом, на тесты обязательно влияет выбор границы. И хорошие стратегии тестирования используют это, чтобы обнаружить/создать границы в коде и сделать их более конкретными. Это улучшает связность в коде (мы определяем вещи, которые вместе могут быть скрыты за границей) и уменьшаем связанность (мы разделяем вещи, которые не должны объединяться за границу).

Но границы часто скрыты

public boolean isUserActive(String userId) {
     RestClientForUserService client = new RestClientForUserService();
     User user = client.get(userId);
     Return user.getIsActive();
}

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

Итак, чтобы иметь возможность написать тест для этого метода, нам нужно иметь возможность контролировать то, что делает служба User. Это можно сделать, фактически настроив пользовательский сервис для запуска этого теста. Но это создает жесткую зависимость для тестирования — не очень хорошая идея. Поэтому нам нужно сделать этот интерфейс с пользовательским сервисом доступным извне, чтобы мы могли создавать различные условия, необходимые для тестирования всех аспектов этого метода. Мы можем сделать это, передав client в качестве аргумента — это сделает скрытую границу более четкой.

Public boolean isUserActive(RestClientForUserService client, String userId) {
    User user = client.get(userId);
    return user.getIsActive();
}

Теперь мы можем смоделировать этот клиент в тесте, чтобы имитировать поведение пользовательского сервиса. Это, в некотором смысле, внедрение зависимостей, и его можно сделать на уровне класса.

Этот способ явной зависимости на самом деле показывает нам еще одну вещь — этому методу не нужно знать о User Service как таковой. Что ему нужно, так это источник объектов User. Таким образом, мы можем еще больше отделить его от пользовательского сервиса, представив для него интерфейс и реализацию, поддерживаемую сервисом.

public interface IUserDataLoader {
    User getUser(userId);
}
public class UserServiceClient implements IUserDataLoader {
    public User getUser(userId) {
           RestClientForUserService client = new RestClientForUserService();
           return client.get(userId);
    }
}
public boolean isUserActive(IUserDataLoader userInterface, String userId) {
     User user = userInterface.get(userId);
     return user.getIsActive();
}

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

Я говорил о внедрении зависимостей ранее. Вот типичный пример из Весенней страны.

@Component
public class MyClass {
    @Autowired
    RestClientForUserService client;
    public boolean isUserActive(String userId) {
        RestClientForUserService client = new RestClientForUserService();
        User user = client.get(userId);
        return user.getIsActive();
    }
}

Раньше мне нравился этот способ внедрения зависимостей — никаких надоедливых конструкторов или шаблонов геттеров/сеттеров. Но когда я начал тестировать это, я продолжал застревать, потому что граница, представленная клиентом службы пользователя, была недоступна. Мне не нравится идея настраивать целые контексты приложения в тестах — я предпочитаю немного меньше магии, а простые тесты тоже работают быстрее. Так что со временем мне понравился метод внедрения конструктора. При написании тестов у меня теперь есть контроль над всеми зависимостями (которые, по сути, представляют собой границы), и я могу манипулировать ими (посредством имитационных или фиктивных реализаций), чтобы проверить все поведение MyClass.

@Component
public class MyClass {
    RestClientForUserService client;
    @Autowired
    public MyClass(RestClientForUserService client) {
        this.client = client;
    }
    public boolean isUserActive(String userId) {
        User user = client.get(userId);
        return user.getIsActive();
    }
}

Давайте возьмем немного больший пример, состоящий из нескольких шагов.

Public class Account {
    String userId;
    Date createdDate;
    Date balance;
}
// Return true if account created, false otherwise
Public boolean createUserAccount(Account account) {
    RestClientForUserService client = new RestClientForUserService();
    User user = client.get(userId);
    If (user == null || !user.isActive()) {
        return false;
    }
    AccountDao dao = new AccountDao();
    try {
        dao.create(account);
    } catch (Exception e) {
        log.error(“Failed creating account”, e);
        return false;
    }
    return true;
}

Если мы хотим правильно протестировать этот метод, нам нужно контролировать каждое место, где код разветвляется. В этом примере ветвление происходит в пользовательском объекте и при попытке записи пользователей в базу данных (UserDao — это класс, который вставляет объект в базу данных, в мире JPA это будет UserRepository). Давайте попробуем здесь другой подход, чем внедрение зависимостей, разделив исходный метод на несколько методов, а затем подключившись к каждому из них с помощью имитации.

Public class Account {
    String userId;
    Date createdDate;
    Date balance;
}
// Return true if account created, false otherwise
Public boolean createUserAccount(Account account) {
    User user = getUser(userId);
    If (user == null || !user.isActive()) {
        return false;
    }
    
    try {
        persistAccount(account);
    } catch (Exception e) {
        log.error(“Failed creating account”, e);
        return false;
    }
    return true;
}
@VisibleForTesting
protected User getUser(String userId) {
    RestClientForUserService client = new RestClientForUserService();
    return client.get(userId);
}
@VisiblForTesting
protected void persistAccount(Account account) {
    AccountDao dao = new AccountDao();
    dao.create(account);
}

Теперь этот код можно протестировать, смоделировав два новых метода — примерно так.

Mockito.doReturn(new User(active = false))
.when(classUnderTest).getUser(Mockito.any());

Если мы хотим, мы все еще можем использовать внедрение зависимостей, чтобы еще больше отделить новые защищенные методы от RestClientForUserService и UserDao. @VisibleForTesting указывает, что метод был сделан защищенным или общедоступным, чтобы его можно было протестировать и не использовать в основном коде. Впрочем, это чисто показательно. Вот почему я не такой большой поклонник этого подхода по сравнению с более простым подходом на основе DI — иногда он заставляет нас раскрывать методы, которые в противном случае были бы закрытыми.

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

Швы кода

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

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

Вот почему я сейчас пишу модульные тесты — чтобы улучшить дизайн моего кода.

Читать далее — Преодоление накладных расходов на ввод-вывод в микросервисах