Протестируй это: принципы и законы создания тестируемого кода

Многие разработчики ненавидят тестирование. Они просто не умеют его готовить. Держите рецепт тестируемого кода с гарниром из SOLID принципов.

Протестируй это: принципы и законы создания тестируемого кода

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

Дизайн-принципы SOLID для тестируемого кода

Роберт Мартин (дядя Боб) сформулировал самый известный в индустрии разработки ПО набор принципов – SOLID. Он должен стать вашей главной поваренной книгой. Всего пять правил (по одному – на каждую букву аббревиатуры) сделают ваш код гораздо вкуснее и полезнее.

Принцип единственной ответственности (Single Responsibility Principle)

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

Представьте, что вы пишете программу, которая обращается к конечной точке REST API. Она получает некоторые данные, обрабатывает их и записывает в CSV-файл. Наивный подход – реализовать все в одном классе. Однако у этого класса слишком много потенциальных причин для изменения:

  • Необходимо изменить обращение к API
  • Изменился источник получения данных
  • Расширился алгоритм обработки
  • Вы решили записывать результаты в файл другого формата
  • Требуется иметь сразу несколько взаимозаменяемых входов и выходов

Следует разбить ваше приложение на несколько отдельных модулей:

interface DataInput {
  Data get();
}

interface DataOutput {
  void write(ProcessingResult result);
}

interface DataProcessingStrategy {
  ProcessingResult process(Data data);
}

class DataProcessor {
  DataProcessor(DataInput dataInput, DataOutput dataOutput, DataProcessingStrategy strategy) {
    ...
  }
  process() {
    Data data = this.dataInput.get();
    ProcessingResult result = this.strategy.process(data);
    this.dataOutput.output(result);
  }
}

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

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

Принцип открытости/закрытости (Open/Closed Principle)

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

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

Чтобы представить принцип открытости/закрытости в действии, подумайте о плагинах. Их задача – добавлять в систему некоторое поведение, не меняя ее код.

Другой хороший и более конкретный пример – метод sort в Java. Он может работать с любым объектом, реализующим интерфейс Comparable. Если же вы для сортировки используете метод с оператором switch, то для каждого нового типа придется его модифицировать, добавляя новые правила.

Принцип открытости/закрытости также подробно описан в блоге Роберта Мартина.

Принцип подстановки Барбары Лисков (Liskov Substitution Principle)

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

Хороший пример для описания этого принципа – проблема квадрата-прямоугольника. У вас может возникнуть соблазн создать интерфейс Shape, реализовать его в классе Rectangle, а затем расширить в Square. Дочерний класс гарантирует, что высота и ширина фигуры всегда одинаковы. На первый взгляд эта структура выглядит неплохо, ведь квадрат по сути – это тот же прямоугольник, но более конкретный. Однако она нарушает принцип подстановки Лисков.

Предположим, что у вас есть метод, который принимает объект Rectangle. Он ожидает, что высота и ширина могут изменяться независимо друг от друга. Значит, вы не сможете передать сюда Square, хоть он и наследует от Rectangle.

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

Принцип разделения интерфейса (Interface Segregation Principle)

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

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

Следование этому принципу помогает сохранять вашу систему слабосвязанной, способствует соблюдению принципа подстановки Барбары Лисков и созданию легко тестируемого кода.

Например, для многофункционального устройства (принтер + сканер) можно создать единый интерфейс MultiFunctionalPrinter с методами print() и scan(). Но лучше использовать два интерфейса Printer и Scaner. Таким образом, если клиенту нужен только метод print(), вы можете предоставить ему простой принтер. Код приложения не изменится, поскольку аспекты сканирования здесь никак не используются.

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

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

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

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

Пример, демонстрировавший принцип единой ответственности, также следует принципу инверсии зависимостей. И высокоуровневые, и низкоуровневые модули здесь зависят от абстракций.

Протестируй это: принципы и законы создания тестируемого кода

Познакомьтесь с абстракциями поближе в посте Gabriel Candal.

Закон Деметры

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

Это означает прежде всего, что вы не должны получать зависимости через другие зависимости, то есть делать вот так:

this.getA().getB().doSomething()

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

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

Больше информации о законе Деметры в посте Miško Hevery.

Еще несколько важных принципов тестируемого кода

Убедитесь, что ваш код имеет швы

Michael Feathers в своей работе Working Effectively with Legacy Code определяет швы как места в программе, в которых можно изменить ее поведение без редактирования.

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

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

Не бросайте инстанцирование на самотек! У вас должно быть два типа классов:

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

Избегайте использования оператора new вне фабрик (за исключением создания простых структур для хранения данных). Если в коде самого приложения будут создаваться новые экземпляры классов, вы не сможете заменить их двойниками при модульном тестировании. Для этого придется использовать monkey-patching или сложные фреймворки для манипуляции байт-кодом.

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

Используйте инъекции зависимостей

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

Обратите внимание, инъекция зависимостей сама по себе очень проста и не требует использования какого-либо фреймворка.

Не используйте глобальное состояние

Глобальные объекты затрудняют понимание кода, так как пользователь может не знать, какие переменные необходимо создать. Это также затрудняет написание тестов, так как они могут влиять друг на друга.

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

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

Статические методы – это процедурный код, в объектно-ориентированной парадигме их следует избегать, так как они не обеспечивают "швов", необходимых для модульного тестирования. Исключением тут являются простые и чистые методы вроде Math.min().

Другие методы, например, System.currentTimeMillis() нельзя заменить тестовым двойником. Вместо этого стоит использовать интерфейс TimestampSupplier или просто Supplier<Long>. Тогда в производственный код можно внедрять реализацию, использующую System.currentTimeMillis(), а в тестовом использовать двойника для создания лучших утверждений.

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

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

Наглядный пример

Посмотрите на этот фрагмент сложно тестируемого кода, в котором сильно связаны несколько частей системы. Этот маленький класс нарушает большинство принципов SOLID, закон Деметры и все остальные рекомендации. Чтобы протестировать его, вам придется использовать фреймворк для манипулирования байт-кодом (например, PowerMock). Такие тесты сложно писать, расширять и поддерживать.

public class MyClass {
  
  public void writeUserName(int id) {
    String userName = App.getDatabaseManager().getUserDatabase().getUserName(id);
    try (FileWriter writer = new FileWriter("user.txt")) {
      writer.write(userName);
    }
  }
}

А вот еще один пример – не идеальный, но намного более простой. Вы легко можете предоставить этому классу фейковый объект UserDatabase и заменить FileWriter, например, на StringWriter.

public class MyClass {
  private final UserDatabase userDatabase;
  
  public MyClass(final UserDatabase userDatabase) {
    this.userDatabase = userDatabase
  }
  
  public void writeUserName(int id, Writer writer) {
    final String userName = this.userDatabase.getUserName(id);
    writer.write(userName);
  }
}

Посмотрите замечательную презентацию Miško Hevery, чтобы лучше разобраться в этих концепциях. Кроме того, обязательно прочитайте две легендарные книги Роберта Мартина: Чистый код и Чистая архитектура. Они сделают из вас гуру проектирования.

Соблюдение перечисленных принципов – отличная основа для написания понятного, поддерживаемого, а главное – легко тестируемого кода.

Источник: Writing Testable Code

А ваш код соответствует этим принципам?

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ