Улучшаем код автотестов: популярные практики и их примеры
Заметки о QAВремя чтения ~15 минут
Сегодня мы погрузимся в изучение нескольких популярных практик, которые помогут сделать ваш код более эффективным и понятным. Для лучшего усвоения теории я подобрала наглядные примеры, которые помогут закрепить полученные знания.
Мы разберем такие подходы, как SOLID, KISS, DRY и YAGNI, и посмотрим их применение на конкретных примерах из проекта. Весь код написан на Java с использованием аннотаций JUnit, но если вы программируете на другом языке, то все равно сможете почерпнуть полезную информацию.
Обратите особое внимание на комментарии в коде – они помогут глубже понять пример и разобраться в проблеме.
Дисклеймер: Для применения практик должна появиться потребность. В идеальном мире маленькие проекты будут также соблюдать все эти принципы, на практике - в этом нет ценности, если мы не будем ее расширять.
SOLID
Начнем разбор практик с набора принципов SOLID.
SOLID - это основа ООП, которая включает в себя пять ключевых принципов, помогающих создавать более понятный, легко поддерживаемый и масштабируемый код. Принципы зашифрованы в самом названии и соответствуют каждой букве в слове SOLID.
Первый принцип SOLID
S (Single-responsibility principle) - принцип единой ответственность: класс должен отвечать только за 1 обязанность. В основе этого принципа лежит разбиение больших классов на меньшие, каждый из которых выполняет одну ответственность. Если вам хочется обогатить класс новым функционалом, а он не вяжется с прошлым, то, возможно, вам стоит создать новый класс.
Один из вариантов применения принципа - это когда ваш тестовый класс отвечает за слишком много проверок или хранит в себе лишнюю функциональность (например, содержит в себе и тест, и метод создания, и наполнение, и удаление сложного объекта).
Пример кода, который не выполнял принцип единой ответственности:
public class UserTest { // обратите внимание, что класс перенасыщен вариантами тестов // тут и вход в систему, и информация о заказах, и выход // в конечном счете мы засунем сюда все тесты, связанные с //действиями пользователей @Test public void shouldLoginTest() { // Тестирование входа в систему } @Test public void shouldGetAllOrdersTest() { // Тестирование получение всех заказов } @Test public void shouldLogoutTest() { // Тестирование выхода из аккаунта } }
Пример, как следует разделить классы, чтобы выполнить принцип:
// первый класс, который отвечает за вход и выход сотрудника public class UserLoginTest { @Test public void shouldLoginTest() { // Тестирование входа в систему } @Test public void shouldLogoutTest() { // Тестирование выхода из аккаунта } } // второй класс, который отвечает за заказы public class OrderTest { @Test public void shouldGetAllOrdersTest() { // Тестирование получение всех заказов } }
Возможно, стоит разделить код на три класса, если тестов на вход и выход будет много. Тут уже стоит основываться на вашем функционале.
Второй принцип SOLID
O (Open-closed principle) - принцип открытости-закрытости: код должен быть открытый для расширения, но закрытый для изменения. То есть то, что уже написано в классе, не должно изменяться, а вот добавить новые методы можно. Почему это важно? Изменения старого кода могут привести к потери важной функциональности, которую вы пытались переписать.
Разберем пример плохой практики (он будет очевидным, в реальной жизни примеры будут потруднее). У нас появился новый функционал. Вместо того, чтобы написать новый тест, мы решили изменить старый. Например, теперь наши данные научились работать с отрицательными значениями числами. И мы просто изменили место в тесте и потеряли кейс проверки:
public class AdditionTest { @Test public void testAddition() { // было: // int result = 1 + 2; // assertThat(result).isEqualTo(3); // на что мы изменили: int result = -1 + 2; assertThat(result).isEqualTo(1); } }
По хорошему мы не должны были менять значения в тесте. Стоило первоначально создать переменные для значений, для теста добавить параметризацию и сохранить логику:
public class AdditionTest { // параметризованные тесты позволят вам // добавлять (!) кейсы проверок, // а не изменять(!) значения в самом тесте @ParameterizedTest @CsvSource({ "1, 2, 3", "-1, 2, 1" }) public void testAddition(int input1, int input2, int expectedResult) { int result = input1 + input2; assertThat(result).isEqualTo(expectedResult); } }
Третий принцип SOLID
L (Liskov substitution principle). - принцип подстановки Барбары Лисков: потомок должен расширять, а не изменять поведение родителя. Легко проверить принцип подстановкой: в коде можно изменить класс-родитель на его ребенка, не внося при этом изменения в код. Это означает, что подклассы должны быть полностью заменяемыми на их базовые классы без нарушения функциональности программы.
Самый очевидный пример может быть с наследованием базового класса и неправильным переопределением его метода . В этом примере класс CustomPage
нарушает LSP, потому что изменяет поведение метода checkHeaderExists()
базового класса BasePage
, что приводит к непредсказуемому результату при вызове этого метода.
import com.codeborne.selenide.SelenideElement; import static com.codeborne.selenide.Selenide.$; public class BasePage { protected SelenideElement header; public BasePage() { header = $(By.className("header")); } public void checkHeaderExists() { header.shouldBe(Condition.visible); } } public class CustomPage extends BasePage { public CustomPage() { super(); header = $(By.className("custom-header")); } @Override public void checkHeaderExists() { header.shouldNotBe(Condition.visible); } } public class LspViolationTest { public static void main(String[] args) { CustomPage customPage = new CustomPage(); customPage.checkHeaderExists(); // В BasePage ожидается проверка наличия элемента, // но в CustomPage проверяется отсутствие элемента "header" } }
Мы не можем просто подставить класс BasePage вместо CustomPage, потому что нарушится логика. Но если мы не можем заменить наследника на родителя, то это обозначает то, что мы нарушаем принцип Барбары Лисков.
Четвертый принцип SOLID
I (Interface segregation principle) - принцип разделения интерфейсов: следует разделять большие классы на логические единицы и уменьшать интерфейсы, чтобы их легко было совмещать и перемещать, а не заставлять классы реализовывать методы из интерфейсов, которые им не нужны.
Для примера разберем интерфейс, который содержит в себе слишком много методов для переопределения. Например, у нас есть интерфейс TestService, который содержит методы для выполнения различных этапов для автотестов: подготовка (before), выполнение (test) и очистка(after):
public interface TestService { void before(); void test(); void after(); }
Обратим внимание, что у нас могут быть тесты, которым не нужен этап after: например, после теста обычно выполняется очистка тестовых данных, а в негативных тестах просто не создаются данные. То же самое относится к before: вдруг нам не нужна подготовка данных? Ненужные методы все равно пришлось бы переопределять, что не очень эффективно.
Поэтому логичнее разделить это на три интерфейса и наши тесты наследовали бы то, что нужно только для них:
public interface PreparingService { void before(); } public interface TestService { void test(); } public interface CleaningService { void after(); }
Пятый принцип SOLID
D (Dependency Inversion Principle) - принцип инверсии зависимостей: использовать не конкретный класс, а интерфейсы, чтобы была возможность легко переиспользовать и расширять код. Пример игнорирования данного принципа мы часто можем встретить у себя на проектах: вместо того, чтобы использовать абстракцию при объявлении драйвера, мы используем конкретный класс ChromeDriver и усложняем себе жизнь, когда нам придется работать с другим браузером:
public class Test() { // используем конкретную реализацию драйвера public ChromeDriver driver; <прочий код, который нас не интересует> }
Как исправиться? Используем интерфейс и облегчаем себе жизнь:
public class Test() { // испольузем высокоуровневую абстракцию // и сможем работать с разными браузерами public WebDriver driver; <прочий код, который нас не интересует> }
Наконец мы закончили с SOLID и можем перейти к не менее важным принципам проектирования кода.
KISS
Начнем с KISS.
KISS (Keep It Simple, Stupid) - принцип гласит: сохраняй код простым и читаемым.
Иногда погоня за написанием функции всего в одну строчку приводит к ситуации, что прочитать код становится просто невозможно. Это плохо. Простота способствует пониманию, поддержке и решению проблем, а также снижает риск ошибок и багов. При разработке функции, которая выполняет несколько задач, лучше разделить её на несколько меньших функций, каждая из которых выполняет одну задачу.
Пример плохого кода привести очень легко: когда ваш тест выполняет все подряд, даже то, что не затрагивает тесты. Например, подготовку окружения и очистку после окончания тестирования:
@Test public void testLogin() { // обратите внимание насколько перегружен тест // тут и открытие браузера Selenide.open("<http://example.com/login>"); // и поиск к элементов SelenideElement usernameField = Selenide.$("#username"); SelenideElement passwordField = Selenide.$("#password"); SelenideElement loginButton = Selenide.$("#login"); // и заполнение значений usernameField.setValue("testuser"); passwordField.setValue("testpassword"); loginButton.click(); // и сразу тут же проверки, которые и нужны для тестирования SelenideElement welcomeMessage = Selenide.$("#welcomeMessage"); welcomeMessage.shouldBe(visible); }
Давайте воспользуемся принципом KISS и постараемся улучшить код: сделаем более понятным и читаемым, разделив логику на отдельные методы и выделив ответственные классы:
// выделили отдельное поля класса PageObject для страницы // со значениями локаторов и логикой входа в аккаунт private LoginPage loginPage; @BeforeEach public void setUp() { // подготовили окружение для тестов Selenide.open("<http://example.com/login>"); loginPage = new LoginPage(); } @Test public void testLogin() { // выполнили тест loginPage.login("testuser", "testpassword"); loginPage.assertWelcomeMessageDisplayed(); } @AfterEach public void tearDown() { // закрыли браузер после теста (или, например, очистили данные) Selenide.close(); }
Важно: принципы SOLID и KISS могут порой друг другу противоречить. Важно помнить, что баланс между ними важен: стремление к простоте и компактности системы не должно идти в ущерб гибкости и модульности, что позволит легко масштабировать и поддерживать код в будущем.
DRY
Следующий немаловажный принцип - это DRY.
DRY (Don’t Repeat Yourself) - принцип переиспользования кода: не повторяй себя.
Если в коде есть повторяющиеся блоки кода, создайте функцию, которая будет выполнять эту задачу, и вызывайте ее там, где это необходимо. Например, если вы где-то уже писали такой же код и потянулись к ctrl+c, чтобы скопировать этот участок, то стоит задуматься о создании отдельного метода для удобства переиспользования.
Пример кода, где человек упустил этот принцип и задублировал код:
public List<String> sendReceiverOrder(List<String> orderList) { // обратите внимание на фильтрацию данных в двух методах return orderList.stream() .filter(value -> value.equals("RECEIVER")) .collect(Collectors.toList()); } public void sendSenderOrder(List<String> orderList) { // не кажется ли это вам одинаковым? return orderList.stream() .filter(value -> value.equals("SENDER")) .collect(Collectors.toList()); }
Применим принцип DRY, избавившись от повторов, и улучшим код:
// вынесем фильтрацию в отдельный метод public List<String> filterByRole(List<String> orderList, String role) { return orderList.stream() .filter(value -> value.equals(role)) .collect(Collectors.toList()); // обратите внимание, насколько проще будет рефакторить код, // если у нас изменится ролей } public List<String> sendReceiverOrder(List<String> orderList) { return filterByRole(orderList, "RECEIVER") } public void sendSenderOrder(List<String> orderList) { return filterByRole(orderList, "SENDER") }
YAGNI
И последний принцип на сегодня, YAGNI (You Ain’t Gonna Need It), говорит о том, что разработчики не должны добавлять функциональность, пока она действительно не потребуется.
Это помогает избежать излишней сложности и избыточности в коде. если сейчас вам не нужна эта функция или класс, не стоит ее и добавлять.
Пример плохого кода: писать функцию на будущее и нигде не использовать ее в коде. 😉
Вывод
Мы прошлись по основным принципам, которые не следует упускать из виду при написании кода. Надеюсь, что примеры вам помогли чуть больше разобраться в принципах и поискать в своем коде ошибки, которые вы могли допустить.
Сначала может быть сложно запомнить всё сразу: невозможно освоить что-то новое с первого раза и исключительно благодаря прочтению статьи. Однако, возвращаясь к теоретическим основам принципов, вы будете чаще замечать свои недочёты. Ваш код станет чище, вам будет легче проводить его рефакторинг и расширение. Дорогу осилит идущий.
До новых встреч! 💓
Полезные материалы
- Один из самых лучших докладов (и вообще материалов) по объяснению SOLID с примерами (отдельная благодарность Диане Вериковой!)
- Статья с пример SOLID на Python
- Статья с пример SOLID на Java
- Улучшение кода с помощью документирования и улучшения наименований автотестов
- Практика хорошего кода
- Статья про принципы разработки
- Советую почитать про GRASP как шаблоны ООП для решения общих задач по назначению ответственностей классам и объектам.
Пост telegram-канала "Заметки о QA"