Взгляд тестировщика на SOLID

Привет, Хабр! Меня зовут Оля, и я старший инженер по тестированию в Lineate. Хочу рассказать о своей попытке осознать SOLID принципы и понять, где их место в автоматизированном тестировании. 

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

Соответственно, этот материал состоит из двух частей:

  • в первой возьмем простое приложение на Java и улучшим его с помощью SOLID принципов — от программы с парой классов, которые делают все подряд, дойдем до приложения, разбитого на несколько модулей с конкретными функциями (да, это еще одно объяснение SOLID, смело пропускайте, если уже и так все знаете);

  • во второй части посмотрим, где во фреймворках автоматизированного тестирования может использоваться SOLID.

SOLID: базовые факты

SOLID — это пять основных принципов объектно-ориентированного программирования и проектирования кода. 

Вот что стоит за каждой из букв в обозначении:

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

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

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

I — ISP, Interface Segregation Principle (Принцип разделения интерфейсов)

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

Принципы SOLID не были искусственно придуманы теоретиками, а скорее обобщают коллективные знания и опыт разработчиков. После нескольких десятилетий работы Роберт «Дядюшка Боб» Мартин объединил их в единую концепцию. А звучный акроним SOLID предложил Майкл Физерс. 

Основными бонусами от использования SOLID принципов должны стать:

  • простой и понятный код, который потребует минимального времени на вхождение от нового разработчика;

  • стабильный код, в который можно максимально безболезненно встраивать новые фичи, запрошенные заказчиком;

  • код с низкой связанностью, над которым в параллель могут работать несколько разработчиков;

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

SOLID принципы используются на уровне модулей и классов в программах, построенных в парадигме ООП. 

Существуют и другие подходы к проектированию кода. Это методологии GRASP, DRY, KISS, YAGNI и др. Каждый из подходов имеет свои особенности, но суть одна — сделать код более простым и удобным для активной разработки и поддержки.

Тестовое приложение

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

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

Так выглядит структура проекта в самом начале (ветка SRP-1):

В проекте есть пакет models, где лежит enum Figure. Тут перечислены все типы фигур, которые поддерживаются приложением на данный момент. 

Здесь же, в пакете models, лежат объекты самих фигур. К примеру, треугольник выглядит так:

@Data public class Triangle {         private Double baseLength;      private Double height; }

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

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

В UserInteraction – методы для общения с пользователем:

public class UserInteraction {     //...       public Figure readFigureFromInput() {        //ask user to enter figure in console and return figure    }     public String readAreaTypeFromInput(Figure figure) {             //ask user to enter area type in console and return area type      }     public void printAreaInConsole(Figure figure, String areaType, Double area) {             //print area in console       } }

Класс CalculateArea – точка входа для вычисления площади. Именно его метод calculateArea(Figure figure, String areaType) вызывается в исполняемом классе Main. В этом методе в зависимости от типа фигуры и типа площади вызываются методы для вычислений. Если нужно посчитать чистую площадь без коэффициентов, используем методы из этого же класса, а если нужна площадь под покраску или для плитки, используем инстанс класса CalculateDecorationArea и его методы для фигур. 

public class CalculateArea {         private CalculateDecorationArea calculateDecorationArea = new CalculateDecorationArea();         public Double calculateArea(Figure figure, String areaType) {          Double area = null;             if (areaType == "simple") {                  if (figure == Figure.CIRCLE) {                        area = calculateCircleArea();                  } else if (figure == Figure.SQUARE) {                        area = calculateSquareArea();                  } else if (figure == Figure.TRIANGLE) {                        area = calculateTriangleArea();                  }            } else if (areaType == "painting") {                  area = calculateDecorationArea.calculateDecorationArea(figure);            } else if (areaType == "tile") {                  area = calculateDecorationArea.calculateDecorationArea(figure);            }       return area;      }         public Double calculateSquareArea() {     //user input and calculations for square      }         public double calculateCircleArea() {           //user input and calculations for circle       }         public double calculateTriangleArea() {           //user input and calculations for triangle   } }

CalculateDecorationArea выглядит очень похоже и отличается только применением коэффициента в формуле расчета площади.

Если попробовать запустить приложение, можно увидеть, что оно вполне нормально работает (дисклеймер: работают только основные кейсы, в коде нет никакой обработки ошибок).

Что здесь не так? Ведь код компилируется, и программа выполняет свои функции. Посмотрим на приложение с точки зрения SOLID принципов.

Окунемся в SOLID на живом примере

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

S – SRP, Single Responsibility Principle 

Первый из принципов. Роберт Мартин в своей книге «Чистая архитектура» расшифровывает его так:

Модуль должен иметь одну и только одну причину для изменения

ПО меняется в ответ на запросы пользователей → Модуль должен отвечать за одну и только одну группу пользователей или заинтересованных лиц, то есть за одного актора → Модуль должен отвечать за одного и только одного актора.

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

Спустимся на уровень классов. Если класс делает вычисления, что-то куда-то отправляет, выводит, описывает логику логирования – это нарушение SRP и антипаттерн. Такой объект можно назвать «божественный объект» или God object.

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

Пример из приложения 

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

Лучше в этом случае разделить вычисления и создать два разных класса. В ветке SRP-1 как раз появляются два отдельных класса CalculatePaintingArea — для маляров с их коэффициентом и CalculateTileArea – для плиточников. 

Пример для маляров с обновленным коэффициентом:

public class CalculatePaintingArea {          private static final Double PAINTING_COEFFICIENT = 1.1;          public Double calculatePaintingArea(Figure figure) {                  Double paintingArea = null;                  if (figure == Figure.CIRCLE) {                   paintingArea = calculateCirclePaintingArea();     } else if (figure == Figure.SQUARE) {       paintingArea = calculateSquarePaintingArea();     } else if (figure == Figure.TRIANGLE) {       paintingArea = calculateTrianglePaintingArea();     }     return paintingArea;       }          public double calculateSquarePaintingArea() {     //user input and calculations for square with coefficient   }      public double calculateCirclePaintingArea() {     //user input and calculations for square with coefficient   }      public double calculateTrianglePaintingArea() {     Double triangleArea;          Scanner sn = new Scanner(System.in);     System.out.println("Enter the length of the triangle base: ");     Double length = sn.nextDouble();     System.out.println("Enter the length of the triangle height: ");     Double height = sn.nextDouble();          triangleArea = length * height / 2 * PAINTING_COEFFICIENT;     return triangleArea;   } }

Можно сказать, что в самих методах для вычисления площадей, например, calculateTrianglePaintingArea() также нарушается принцип SRP. Помимо формулы площади метод содержит блок взаимодействия с пользователем.

Поменяем методы с вычислениями. Теперь они принимают на вход названия фигур, делают вычисления и возвращают площадь.

public double calculateTriangleArea(Triangle triangle) {      return triangle.getBaseLength() * triangle.getHeight() / 2 * PAINTING_COEFFICIENT; }

Взаимодействие с пользователем было вынесено в UserInteraction класс. Теперь там есть методы для создания фигур по измерениям, которые вводит пользователь. Например, для квадрата:

public Square createSquareWithUserInput() {     Square square = new Square();     System.out.println("Enter length of the square side: ");    Double length = myObj.nextDouble();     square.setSideLength(length);     return square; }

В ветке SRP-2 уже есть улучшения в соответствии с принципом SRP. Здесь я разделила код для маляров и плиточников и вынесла из метода для вычисления площади код для создания фигуры с измерениями, заданными пользователем. В итоге в программе появилось два небольших модуля: один для взаимодействия с пользователем, а второй – для вычислений. Соответственно, в структуре кода теперь два разных пакета: calculations и user.interactions

В чем преимущества:

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

  2. Вычисления для разных групп акторов разделены. Приложением могут пользоваться проектировщики, которым нужна чистая площадь, а также маляры и плиточники. Изменения, нужные одним, не сломают логику, которая работает для других.

Результат: код стал проще, появились небольшие более понятные кусочки, «Божественный объект» был разделен на отдельные кирпичики, из которых можно построить большее количество вариаций.

O – OCP, Open/ Closed Principle 

Второй принцип из пятерки SOLID – принцип открытости/ закрытости. Для консистентности снова обратимся к формулировке Роберта Мартина из книги «Чистая архитектура»:

Программные сущности должны быть открыты для расширения и закрыты для изменения

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

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

Пример из приложения

Давайте попробуем найти нарушение OCP в текущем состоянии приложения для дизайнеров и отделочников. Для этого перейдем на ветку OCP-1. Что будет, если мы решим поменять формулу для расчета площади треугольника и, соответственно, измерения, которые нужны для ее вычисления? К примеру, пользователям нашего приложения сложно измерить высоту треугольника, но измерить длину сторон и угол между ними они могут. Поэтому поменяем нашу модель треугольника и изменим формулу в классе CalculateSimpleArea. К чему это приведет? Код будет содержать ошибки, так как другие классы для расчетов (например, CalculatePaintingArea) все еще будут указывать на несуществующие параметры треугольника: высоту и длину основания.

Чтобы применить новую формулу для площади, придется поменять ее во всех классах с калькуляциями. Здесь нарушен принцип открытости/ закрытости: добавление нового функционала в одном месте ведет к возникновению ошибок в других. Попробуем решить эту проблему. 

В ветке OCP-2 формулы остались только в одном классе CalculateArea (в нем уже есть новая формула для вычисления площади треугольника).

public double calculateTriangleArea(Triangle triangle) {     return triangle.getFirstSideLength() * triangle.getSecondSideLength() / 2 * Math.sin(Math.toRadians(triangle.getFirstAndSecondSidesAngle())); }

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

public class CalculatePaintingArea extends CalculateArea {     private static final Double PAINTING_COEFFICIENT = 1.1;     private static UserInteraction userInteraction = new UserInteraction();     public Double calculatePaintingArea(Figure figure) {         Double paintingArea = null;         if (figure == Figure.CIRCLE) {            Circle circle = userInteraction.createCircleWithUserInput();            paintingArea = calculateCircleArea(circle) * PAINTING_COEFFICIENT;        } else if (figure == Figure.SQUARE) {            Square square = userInteraction.createSquareWithUserInput();            paintingArea = calculateSquareArea(square) * PAINTING_COEFFICIENT;        } else if (figure == Figure.TRIANGLE) {            Triangle triangle = userInteraction.createTriangleWithUserInput();            paintingArea = calculateTriangleArea(triangle) * PAINTING_COEFFICIENT;        }        return paintingArea;    } }

С изменением формулы пришлось изменить и структуру объекта Triangle, а именно — его поля. Метод для расчета площади фигуры напрямую зависит от ее измерений. Логичнее будет держать этот метод в классе, который описывает фигуру. В ветке OCP-3 я внесла изменения в классы, описывающие фигуры. Так, например, теперь выглядит треугольник:

@Data public class Triangle extends Figure {     private Double firstSideLength;    private Double secondSideLength;    private Double firstAndSecondSidesAngle;     public Double getArea() {         return firstSideLength * secondSideLength / 2                * Math.sin(Math.toRadians(firstAndSecondSidesAngle));    } }

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

Итак, в результате того, что мы нашли и исправили нарушения принципа открытости/ закрытости, в приложение стало легче вносить изменения:

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

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

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

Результат:

  • проще вносить изменения, они не ломают все приложение;

  • над расширением функционала программы могут работать несколько разработчиков в параллель.

L – LSP, Liskov Substitution Principle 

Принцип подстановки Барбары Лисков – еще один из принципов SOLID. С его пониманием у меня возникло больше всего проблем. На сегодняшний день существует множество вариантов и переосмыслений этого принципа. В своей работе Data Abstraction and Hierarchy 1988 года Барбара Лисков сформулировала определение подтипов:

Если для каждого объекта o1 типа S существует такой объект o2 типа Т, что для всех программ Р, определенных в терминах T, поведение Р не изменится при подстановке o2 вместо o1, то S является подтипом T

Более доступная формулировка звучит так:

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

Еще одна формулировка для лучшего понимания (Герберт и Александреску «Стандарты программирование на С++»): 

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

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

Пример из приложения

Для того, чтобы понять принцип LSP, посмотрим на его нарушение на классическом примере.

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

В ветке LSP-1 новый класс Rectangle наследуется от Figure

@Data public class Rectangle extends Figure {     private Double width;    private Double height;     public Double getArea() {         return width * height;    } }

А Square теперь наследуется от Rectangle и фактически в нем ничего не меняется по сравнению с Rectangle

@Data public class Square extends Rectangle {}

Во всех классах с вычислениями теперь тоже появился Rectangle

В соответствии с принципом подстановки Лисков программы должны иметь возможность использовать экземпляр класса-наследника вместо базового класса без каких-либо дополнительных изменений. Поэкспериментируем с методом UserInteraction.readFigureFromInput(), который вызывает создание фигуры в зависимости от ввода пользователя. Попробуем использовать Square вместо Rectangle

public Figure readFigureFromInput() {     Figure figure = null;     System.out.println("Enter figure type from the list. Enter the number and press Enter: " +            "\n 1 - Square \n 2 - Circle \n 3 - Triangle \n 4 - Rectangle");     Integer figureNumber = Integer.parseInt(myObj.nextLine());       if (figureNumber == 1) figure = new Square();    else if (figureNumber == 2) figure = new Circle();    else if (figureNumber == 3) figure = new Triangle();    else if (figureNumber == 4) figure = new Square();    return figure; }

Теперь попробуем вычислить площадь прямоугольника и квадрата. При выборе прямоугольника создается подтип, то есть Square, поэтому мы задаем только одну сторону. У Rectangle их должно быть две, поэтому в результате получаем сообщение об ошибке:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Double.doubleValue()" because "this.height" is null

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

Чтобы приложение как-то заработало, мы могли бы добавить оверрайд в сеттеры для квадрата. В той же ветке LSP-1 уже есть закомментированный код, который позволяет при создании квадрата как подтипа прямоугольника записывать одно измерение как высоту и как ширину. Раскомментируем его.

@Data public class Square extends Rectangle {     @Override    public void setHeight(Double height) {        super.setHeight(height);        super.setWidth(height);    }     @Override    public void setWidth(Double width) {        super.setWidth(width);        super.setHeight(width);    } }

При запуске программы получим правильные вычисления для квадрата. Но использовать Square как подтип Rectangle по-прежнему не можем: вместо площади прямоугольника получаем площадь квадрата. То есть в программе есть части, которые не могут работать с подтипом прямоугольника как с самим прямоугольником. А значит, тут нарушен принцип подстановки Барбары Лисков и неправильно используется наследование.

В ветке LSP-2 и Square, и Rectangle наследуются от Figure и имеют свои кейсы использования.

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

I – ISP, Interface Segregation Principle 

Предпоследний принцип говорит о разделении интерфейсов. Здесь предполагается разбиение обширных интерфейсов с большим количеством методов на более мелкие, объединяющие методы в соответствии с бизнес-логикой. Формулировка принципа ISP:

Программные сущности не должны зависеть от методов, которые они не используют

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

Пример из приложения

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

В ветке ISP-1 уже добавлен новый пакет output, в котором хранится интерфейс ProcessResult. Интерфейс содержит 5 методов. Два – для подготовки, предварительной обработки данных, и три – для вывода в разные места.

public interface ProcessResult {     List<String[]> prepareDataAsList(Figure figure, String areaType, Double area);     Map<String, Object> prepareDataAsMap(Figure figure, String areaType, Double area);     void writePreparedListDataToFile(List<String[]> preparedListData);     void writePreparedMapDataToDb(Map<String, Object> preparedMapData);     void writePlainDataToConsole(Figure figure, String areaType, Double area); }

Тут же в пакете output содержится три класса, реализующих вывод результата в консоль, файл и в базу данных. В каждом классе есть всего 1-2 метода интерфейса, которые действительно нужны данному классу и реализуются в нем, остальные – пустые. Пример для вывода в файл:

public class SaveResultToCsvFile implements ProcessResult {     private List<String[]> dataLines = new ArrayList<>();      @Override    public List<String[]> prepareDataAsList(Figure figure, String areaType, Double area) {        dataLines.add(new String[] {"Figure", "Type of area", "Area"});        dataLines.add(new String[]{figure.getClass().getSimpleName(), areaType, area.toString()});         return dataLines;    }     @Override    public Map<String, Object> prepareDataAsMap(Figure figure, String areaType, Double area) {        // Not needed here        return null;    }     @Override    public void writePreparedListDataToFile(List<String[]> preparedListData) {         File csvOutputFile = new File("C:\\Users\\o_kus\\Documents\\solid-example\\new_file-"                + Arrays.stream(preparedListData.get(1)).findFirst().get() + new Random().nextInt() + ".csv");         try (PrintWriter pw = new PrintWriter(csvOutputFile)) {            dataLines.stream()                    .map(this::convertLineToCSV)                    .forEach(pw::println);        } catch (FileNotFoundException e) {            e.printStackTrace();        }    }     @Override    public void writePreparedMapDataToDb(Map<String, Object> preparedMapData) {        // Not needed here    }     @Override    public void writePlainDataToConsole(Figure figure, String areaType, Double area) {        // Not needed here    }     String convertLineToCSV(String[] dataLines) {        return Stream.of(dataLines)                .collect(Collectors.joining(","));    } }

В Main используем два класса из output: SendResultToConsole для вывода в консоль и SaveResultToCsvFile для записи в файл. Реализацию для БД в рамках этого примера я прописывать не стала.

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

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

Посмотрим, как это исправить в ветке ISP-2. Тут вместо одного «толстого» интерфейса есть три разных, каждый из которых содержит методы, нужные для этого типа вывода результата. Посмотрим пример для вывода в файл:

public interface FileOutput {     List<String[]> prepareDataAsList(Figure figure, String areaType, Double area);     void writePreparedListDataToFile(List<String[]> preparedListData); }

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

public class StringConsoleAndCsvFileOutput implements ConsoleOutput, FileOutput {     private List<String[]> dataLines = new ArrayList<>();     @Override    public void writePlainDataToConsole(Figure figure, String areaType, Double area) {              //some code to write to console      }     @Override    public List<String[]> prepareDataAsList(Figure figure, String areaType, Double area) {        //some code to convert input data to list    }     @Override    public void writePreparedListDataToFile(List<String[]> preparedListData) {        //some code to write to file     } … }

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

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

D – DIP, Dependency Inversion Principle 

Разберем заключительный принцип из пятерки SOLID. Мы уже затрагивали организацию зависимостей в разделе про принцип открытости/ закрытости, здесь посмотрим на зависимости пристальнее. DIP декларирует: 

Модули верхних уровней не должны импортировать сущности из модулей нижних уровней. Оба типа модулей должны зависеть от абстракций

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

DIP позволяет бороться с признаками плохого дизайна кода. Роберт Мартин определяет эти признаки так:

  • rigidity — дизайн можно назвать жестким, когда его тяжело изменить, любое изменение в одной части системы затрагивает большое количество других частей;

  • fragility — хрупкий дизайн тот, при котором изменения в одной части системы приводят к неожиданным нарушениям в других частях;

  • immobility — неподвижный дизайн, при котором код настолько сильно «вплетен» в приложение (слишком много беспорядочных зависимостей), что невозможно вычленить какую-то самостоятельную часть для переиспользования в других приложениях.

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

Пример из приложения

Давайте посмотрим на ветку DIP-1 (фактически это то же состояние кода, что и в предыдущей ветке, где мы применяли ISP). Пришло время улучшить вычисления и продемонстрировать силу принципа инверсии зависимостей. Каждый класс с вычислениями сейчас проверяет инстанс фигуры, полученной на вход, а затем вызывает метод getArea() у конкретного объекта. Все это выглядит громоздко и получается, что в вычислениях все равно завязываемся на детали. Вот пример для вычисления площади под покраску:

public class CalculatePaintingArea implements CalculateArea {     private static final Double PAINTING_COEFFICIENT = 1.1;     private static UserInteraction userInteraction = new UserInteraction();      @Override    public Double calculateArea(Figure figure) {         Double area = null;         if (figure instanceof Square) {             Square square = userInteraction.createSquareWithUserInput();            area = square.getArea() * PAINTING_COEFFICIENT;         } else if (figure instanceof Circle) {         //some code for other figures        return area;    } }

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

Попробуем это исправить в ветке DIP-2. Что тут изменилось? Теперь Figure — это абстрактный класс, у которого есть метод getArea(). Метод абстрактного класса мы можем реализовать, а можем оставить реализацию классам, которые будут его имплементировать. Выберем второй вариант. 

public abstract class Figure {     public abstract Double getArea(); }

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

public class CalculatePaintingArea implements CalculateArea {     private static final Double PAINTING_COEFFICIENT = 1.1;     @Override    public Double calculateArea(Figure figure) {         return figure.getArea() * PAINTING_COEFFICIENT;    } }

В этой ветке DIP можно увидеть еще в одном месте. Я разбила пакет input на варианты с введением данных через консоль и созданием фигур из файлов. И создала интерфейс CreateInput, который предоставляет два метода — для получения фигуры и типа вычислений. Клиентскому коду достаточно использовать нужную имплементацию интерфейса, сам код при этом не меняется.

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

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

SOLID в автоматизированном тестировании

Мы разобрались с тем, как работает SOLID в разработке. Но моя сфера интересов – это тестирование и обеспечение качества программных продуктов. Именно поэтому мне было важно узнать, где место принципов проектирования кода в автоматизированном тестировании. Ответ достаточно предсказуемый – фреймворки автоматизированного тестирования. По факту, это такие же программы, у которых есть своя архитектура, а, значит, SOLID может активно применяться и здесь. Разберем несколько примеров.

SRP

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

Наше приложение – дашборд, на котором можно посмотреть метрики и атрибуты сущностей в разных измерениях — от самых общих отчетов до детальных (назовем их Dimension1, Dimension2 для примера). Интеграционные тесты для API заключались в том, что мы сравнивали ответ API с данными из баз данных для каждого измерения. Базы данных было две: в MySQL хранились атрибуты сущностей, а в ClickHouse – метрики. При этом структура классов, отвечающих за получение данных из баз, выглядела следующим образом:

В коде JdbcRepository описано подключение к абстрактной БД и создание namedParameterJdbcTemplate для работы с данными из баз.

public abstract class JdbcRepository {    protected final NamedParameterJdbcTemplate namedParameterJdbcTemplate;     protected JdbcRepository(String driverClassName, String url, String username, String password) {        var dataSource = new DriverManagerDataSource();        dataSource.setDriverClassName(driverClassName);        dataSource.setUrl(url);        dataSource.setUsername(username);        dataSource.setPassword(password);         this.namedParameterJdbcTemplate = new NamedParameterJdbcTemplate(dataSource);    } }

Далее в классах для получения данных для конкретных измерений передавалась информация для подключения к БД и содержались специфические для конкретного измерения методы. Например:

public class Dimension1MySqlRepository extends JdbcRepository {     public Dimension1MySqlRepository(String url, String username, String password) {        super("com.mysql.cj.jdbc.Driver", url, username, password);    }     public Integer findDimension1CountByEntityId(Integer entityId) {        var query = readFile("path_to_sql_file");        var paramMap = Map.of("entityId", entityId);         return namedParameterJdbcTemplate.queryForObject(query, paramMap, (rs, rowNum) -> rs.getInt("COUNT"));    }     ... }

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

OCP 

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

Пример организации сущностей во фреймворке для тестирования интернет-магазина:

public abstract class Customer {...}  public class OrdinaryCustomer extends Customer {...}  public class SilverCustomer extends Customer {...}  public class GoldCustomer extends Customer {...}

LSP 

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

Если вы используете Java и JUnit5 в своем тестовом фреймворке, то привести систему в нужное состояние перед началом теста можно при помощи методов, помеченных аннотациями @BeforeEach и @BeforeAll, а добиться первоначального состояния можно при помощи @AfterEach и @AfterAll. Если одни и те же пред- и постусловия нужны практически для всех тестов или для группы тестов, их можно вынести в класс BaseTest. В тестовом классе нужно просто наследоваться от базового. 

Пример BaseTest…

public class BaseTest {     @BeforeAll    void openDatabaseConnection() {        //some code    }       @AfterAll    void closeDatabaseConnection() {        //some other code    } }

и тестового класса:

public class ApiTest extends BaseTest {     @Test    public void getResponse_shouldReturnDataFromDb_whenSendValidInput() {        //some test code    } }

Перед выполнением всех тестов тестового класса отработает метод openDatabaseConnection() из базового класса, а после — closeDatabaseConnection(). Нарушением LSP тут будет наследование от базового класса в тесте, где нужны другие пред- или постусловия.

ISP 

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

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

public interface Discount {       int getDiscountAmount(); }  public interface FreeDelivery {     void getFreeDelivery(); }  public class OrdinaryCustomer extends Customer implements Discount {    @Override    public int getDiscountAmount() {        return 5;    }  }  public class GoldCustomer extends Customer implements FreeDelivery, Discount {    @Override    public int getDiscountAmount() {        return 25;    }     @Override    public void getFreeDelivery() {        //some code    }      //some other code }

DIP 

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

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

public class HomePage {     private WebDriver driver;      public HomePage(WebDriver driver) {         this.driver = driver;     }      public void navigateToUserProfile() {         driver.findElement(By.id("profile")).click();     }     //some other code }

Выводы

За время изучения принципов SOLID я усвоила несколько важных вещей: 

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

  2. Эти принципы не являются чем-то вроде чек-листа при написании автотестов или программного кода – они скорее про общее понимание и культуру разработки.

  3. Далеко не всегда стоит усложнять код абстракциями или применять какие-то другие принципы просто для галочки. Здесь важно понимание всей архитектуры и целесообразности использования тех или иных подходов. 

  4. Все SOLID принципы тесно связаны между собой и соблюдение одного часто ведет и к соответствию другому.

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


ссылка на оригинал статьи https://habr.com/ru/company/lineate/blog/674144/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *