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

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

Зачем мы тестируем наш код?

Давайте быстро вспомним, почему тесты так важны для разработки программного обеспечения. Есть много преимуществ написания тестов, вот некоторые из них:

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

Что делает ваш код тестируемым?

По сути, написание «чистого» кода является ключевым моментом в тестируемом коде. Некоторые принципы, законы и пункты направляют нас на этом пути.

Принципы проектирования SOLID

В разработке программного обеспечения SOLID — это аббревиатура пяти принципов проектирования, которые делают разработку программного обеспечения более понятной, гибкой и удобной в сопровождении, задокументированных Робертом С. Мартином (также известным как дядя Боб).

Согласно принципам SOLID, ваш код должен следовать следующим принципам.

Принцип единой ответственности (SRP)

Каждый программный модуль должен иметь одну и только одну причину для изменения

Другая формулировка этого принципа (уточнил Роберт Мартин):

Соберите вместе вещи, которые меняются по одним и тем же причинам. Разделяйте те вещи, которые меняются по разным причинам.

Это еще один способ определить связность и связь. Мы должны увеличить связь между модулями, которые изменяются по одним и тем же причинам, и уменьшить связь между теми модулями, которые изменяются по разным причинам, как гласит этот принцип.

Этот принцип можно применить к уровням классов и методов. Однако мы должны быть осторожны, чтобы не переусердствовать. В нем не говорится, что модуль должен делать только одну вещь, все дело в ответственности (то есть «причине изменения»).

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

public class Employee {

     public Double calculatePay() {...}
     public void saveEmployee() {...}

     public void getEmployeeReport() {...}

}

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

Принцип открытия/закрытия (OCP)

Программные объекты должны быть открыты для расширения, но закрыты для модификаций.

Другая формулировка этого принципа (уточнил Роберт Мартин):

Вы должны иметь возможность расширять поведение системы, не изменяя ее.

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

В качестве простого примера вы можете подумать о программе AreaCalculator, которая сначала вычисляет площадь Rectangle.

public class Rectangle {
     public Double length;
     public Double width;
}
public class AreaCalculator {
     public Double calculateRectangleArea(Rectangle rectangle){        
          return rectangle.length * rectangle.width;
     }
}

Этот код справляется, но нарушает OCP, потому что если вы хотите добавить в программу еще одну фигуру, вы должны полностью изменить метод calculateRectangleAre.

Правильный код, следующий за OCP, должен быть следующим.

public interface Shape {
     public Double calculateArea();
}

public class Rectangle implements Shape {
     Double length;
     Double width;

     public double calculateArea(){
          return length * width;
     }
}

public class Circle implements Shape {
     public Double radius;

     public Double calculateArea(){
           return (22 / 7) * radius * radius;
     }
}
public class AreaCalculator {

     public Double calculateShapeArea(Shape shape) {
           return shape.calculateArea();
     }
}

Каждый объект формы, площадь которого будет вычисляться, должен реализовывать интерфейс ShapeиAreaCalculator должен выполнять вычисления над интерфейсными объектами.

Принцип замещения Лискова (LSP)

Объекты суперкласса и объекты его подклассов должны быть взаимозаменяемыми без нарушения работы приложения.

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

Другим примером нарушения этого правила могут быть методы, генерирующие UnsupportedOperationException для некоторых конкретных реализаций подклассов. Чтобы решить эту проблему, необходимо пересмотреть и перестроить иерархию наследования. Пример BookDelivery показывает нарушение принципа LSP и предлагает решение.

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

Принцип разделения интерфейсов (ISP)

Ни один клиент не должен зависеть от методов, которые он не использует.

Как бы просто это ни звучало, это также легко игнорировать. Нам следует избегать реализаций с методами, которые выдают NotSupportedExceptionили UnsupportedOperationException, что указывает на то, что определенная операция не поддерживается в некоторых конкретных реализациях.

Этот принцип имеет два момента.

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

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

Например, подумайте об интерфейсе под названием RestaurantInterface, которыйимеет множество методов для онлайн-заказов, заказов по телефону и при входе клиентов. Если онлайн-служба поддержки клиентов реализует этот интерфейс, она также должна реализовать методы обслуживания по телефону и в режиме ожидания, которые здесь «не поддерживаются». Это пример нарушения ISP, и его следует решать путем «разделения» интерфейсов.

Принцип инверсии зависимостей (DIP)

Модули высокого уровня не должны зависеть от модулей низкого уровня; оба должны зависеть от абстракций.

Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.

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

Вот пример PasswordReminder, который подключается к базе данных MySQL.

public class MySQLConnection {
    public String connect() {
        // handle the database connection
        return "Database connection";
    }
}

public class PasswordReminder {
    private MySQLConnection dbConnection;

    public PasswordReminder(MySQLConnection dbConnection) {
        this.dbConnection = dbConnection;
    }
}

MySQLConnection — это модуль низкого уровня, а PasswordReminder — это модуль высокого уровня. Приведенная выше реализация нарушает DIP, потому что здесь PasswordReminder принудительно зависит от MySQLConnection. НоPasswordReminderне должно интересовать подключение к БД.

С приведенным ниже изменением кода как MySQLConnection, так и PasswordReminder зависят от абстракции, и это следует за DIP.

public interface DBConnectionInterface {
    public String connect();
}
public class MySQLConnection implements DBConnectionInterface {
    public String connect() {
        // handle the database connection
        return "Database connection";
    }
}

public class PasswordReminder {
    private DBConnectionInterface dbConnection;

    public PasswordReminder(DBConnectionInterface dbConnection) {
        this.dbConnection = dbConnection;
    }
}

Примеси и тестируемость

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

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

Рассмотрим приведенный ниже код, который является идеальной иллюстрацией чистой функции.

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

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

Как насчет примера нечистой функции, как показано ниже.

public static String GetTimeOfDay()
{
   LocalDateTime time = LocalDateTime.now();
   if (time.getHour() >= 0 && time.getHour() < 6)
   {
      return "Night";
   }
   if (time.getHour() >= 6 && time.getHour() < 12)
   {
      return "Morning";
   }
   if (time.getHour() >= 12 && time.getHour() < 18)
   {
      return "Afternoon";
   }
   return "Evening";
}

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

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

Assert.AreEqual("Morning", GetTimeOfDay());

Примесь вредна и токсична. Если метод foo() зависит от другого метода bar(), имеющего побочные эффекты и недетерминированного, то foo() также становится «нечистым». Когда вы адаптируете эту проблему к сложным реальным приложениям, мы можем столкнуться с повреждением кодовой базы, полной запахов, антишаблонов и уродливых неприятных кодов, которые в конечном итоге непроверяемы.

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

Некоторые полезные советы и рекомендации

В дополнение к принципам SOLID есть еще несколько моментов, которые следует учитывать при написании тестируемого кода. Здесь указаны некоторые из них.

Напишите более чистые функции

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

Избегайте циклических зависимостей

Если два или более модулей (прямо или косвенно) зависят друг от друга для правильного функционирования, возникает циклическая зависимость. Это противоречит принципу инверсии зависимостей (DIP). Если это произойдет, вы должны реорганизовать свой код и переосмыслить новый способ остановить циклические зависимости.

Свернуть глобальные объявления

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

Синглтоны — это пример глобального состояния, поэтому вы должны быть осторожны с ним и избегать синглтонов большую часть времени. Внедрение зависимостей следует предпочесть для передачи экземпляров объектам.

Не смешивайте построение объекта с логикой приложения

В вашем приложении должно быть два типа классов: фабрики и классы приложений.

Фабрики — это места, где находятся все операторы new, и они должны нести ответственность только за создание объектов и зависимостей.

С другой стороны, классы приложений содержат всю бизнес-логику. Вместо того, чтобы создавать объекты, они просто просят фабрики создать или получить объекты. Это упрощает тестирование логики приложения, заменяя реальные классы тестовыми двойниками.

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

Использовать внедрение зависимостей

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

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

В приведенном ниже примере показан код, который сложно протестировать. Здесь ClassB является зависимостью от ClassA, а ClassA тесно связан с контекстом приложения для получения ClassB.

public class ClassA {
    public int calculateTenPercent() {     
         return App.getClassB().calculate() * 0.1d;
    }
}

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

public class ClassA {
    ClassB classB;
    public ClassA(ClassB classBParam) {
         this.classB = classBParam;
    }
    public int calculateTenPercent() {     
         return classB.calculate() * 0.1d;
    }
}

Избегайте статических методов

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

Исключениями из этого правила являются простые и чистые методы, такие как Math.min(). Однако вы можете избегать прямого использования некоторых других статических методов, таких как System.currentTimeMillis(), так как вы не сможете заменить его тестовым двойником. Вместо этого, если вы можете использовать его через интерфейс Supplier, вы также можете подделать его реализацию в своем тесте.

Предпочитайте композицию наследованию

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

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

Предпочитайте полиморфизм условным операторам

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

Этот пост содержит несколько советов по написанию тестируемого кода. Хитрость заключается в том, чтобы преобразовать эти абстрактные концепции в конкретные решения вашего кода, чтобы улучшить его тестируемость.

Следуя этим принципам и рекомендациям, вы получите не только тестируемый, но и более чистый и удобный для сопровождения код!

Удачного кодирования!