(Статья - результат совместной работы с Натальей Поляковой)
На практике знание того, как НЕ писать тесты, может быть столь же важно, как и знание того, как их писать. В интернете можно найти множество материалов про “тесты с запашком”; в частности, им посвящено несколько очень полезных глав в книге Джерарда Месароша о паттернах в xUnit.
Нам показалось интересным подойти к этой проблеме не со стороны теории, а со стороны практики: какие частые ошибки можно встретить в тестах, как их исправлять, и почему именно тесты нужно писать так, а не иначе? Мы продемонстрируем всё это для стека JUnit + Selenide.
Все примеры доступны в репозитории на GitHub, где отдельно лежат первая, вторая и третья итерации нашей работы с тестами.
Предисловие: скрывать или не скрывать
Есть два подхода к написанию автотестов:
прятать как можно больше кода в page objects, чтобы тесты были как можно короче
оставлять всё в самих тестах
Оба подхода имеют право на существование. Первый предпочтителен, если у вас сложный UI и много кода переиспользуется. Если же сложность структуры в основном на стороне бэкенда и вы планируете тестировать через API в обход UI, то лучше работать с легковесными page objects.
Использовать можно и тот, и другой подход — главное, чтобы вся команда делала это единообразно. В нашем примере мы будем следовать первому подходу (прятать код в page objects), чтобы продемонстрировать некоторые структурные проблемы.
Первая версия, с “запахами”
Перейдём к нашему примеру. Предположим, нам с вами дали отрефакторить несколько тестов. Нам многое в них не нравится:
import com.codeborne.selenide.Condition;
import com.github.javafaker.Faker;
import io.qameta.allure.Step;
import org.junit.jupiter.api.Test;
import static com.codeborne.selenide.Selenide.$;
import static com.codeborne.selenide.Selenide.open;
public class BadE2E {
// У шага нет описания, мы не знаем, что он делает.
// Мы не можем быть уверены, что страница загрузилась
@Step
public void openAuthorizationPage() {
open("https://www.saucedemo.com");
}
// Элементы не помещены в переменные или page objects,
// поэтому их нельзя переиспользовать.
// Данные захардкожены, хотя могли бы передаваться извне.
@Step
public void authorize() {
$("#user-name").setValue("standard_user");
$("#password").setValue("secret_sauce");
$("#login-button").click();
}
// Селекторы основаны на классах, а не на ID
@Step
public void checkUserAuthorized() {
$(".app_logo").shouldBe(Condition.visible);
}
// Небезопасная проверка: если страница загружается долго,
// проверка пройдет, потому что логотип присутствует
// на предыдущей странице
@Step
public void checkUserNotAuthorized() {
$(".login_logo").shouldBe(Condition.visible);
}
// Тест ничего не тестирует
@Test
public void shouldAuthorizeUser() {
openAuthorizationPage();
authorize();
}
// 1. Открытие главной страницы не вынесено в фикстуру
// 2. Метод 'authorize()' не переиспользуется
// 3. Данные генерируются в отдельном классе, что излишне на этом этапе
@Test
public void shouldNotAuthorizeUserWithInvalidPassword() {
Faker faker = new Faker();
TestUser user = new TestUser();
openAuthorizationPage();
$("#user-name").setValue(user.username);
$("#password").setValue(faker.internet().password());
$("#login-button").click();
checkUserNotAuthorized();
}
// Экземпляр класса Faker создается в каждом тесте,
// хотя одного было бы достаточно.
// Тесты практически идентичны; их следует параметризовать.
@Test
public void shouldNotAuthorizeUserWithInvalidUsername() {
Faker faker = new Faker();
TestUser user = new TestUser();
openAuthorizationPage();
$("#user-name").setValue(faker.name().username());
$("#password").setValue(user.password);
$("#login-button").click();
checkUserNotAuthorized();
}
// Один тест содержит несколько проверок.
// Тесты зависят друг от друга, что может вызвать нестабильность.
// Проверки идентичны.
@Test
public void shouldNotAuthorizeUserWithEmptyAndBlankInputs() {
openAuthorizationPage();
$("#login-button").click();
checkUserNotAuthorized();
$("#user-name").setValue(" ");
$("#password").setValue(" ");
$("#login-button").click();
checkUserNotAuthorized();
$("#user-name").clear();
$("#password").clear();
$("#login-button").click();
checkUserNotAuthorized();
}
}
Начнём с анализа наиболее общих проблем.
Рефакторинг
Читаемость
Напрямую проблем с читаемостью в примере нет — например, имена вполне приемлемые. Тем не менее, читаемость можно было бы улучшить, добавив описания к шагам.
Раз мы уже используем аннотацию Allure @Step
для обозначения шагов, с помощью неё можно было бы добавить описание: @Step("Открыть страницу логина")
. Это удобный способ хранить документацию, и, кроме того, позволяет создать отчёт, понятный не только тестировщикам:

Структура
В наших тестах много ненужных повторений. Если мы от них избавимся, тесты станут короче и тем самым более удобными для чтения.
Каждый тест открывает страницу регистрации. Это действие можно было бы перенести в отдельную фикстуру, вызываемую перед каждым тестом. Кроме того, поскольку все тесты используют один и тот же URL, имеет смысл перенести его в Configuration.baseUrl
.
@BeforeEach
public void setUp() {
Configuration.baseUrl = "https://www.saucedemo.com";
openAuthorizationPage();
}
Теперь главную страницу можно открыть простым open(“”)
.
Следующая проблема: мы создаем по экземпляру Faker
для каждого теста. Этого можно избежать, если хранить один экземпляр как поле класса.
Идём дальше. Нам следует перенести селекторы в отдельные переменные:
SelenideElement inputUsername = $("#user-name");
SelenideElement inputPassword = $("#password");
SelenideElement buttonLogin = $("#login-button");
Таким образом, вам не нужно запоминать или искать селектор, когда пишете новый тест. Помимо этого, код становится проще изменять и поддерживать.
Метод authorize()
Мы можем добиться большего переиспользования, если улучшим метод, которым авторизуемся на веб-странице:
@Step
public void authorize() {
$("#user-name").setValue("standard_user");
$("#password").setValue("secret_sauce");
$("#login-button").click();
}
Сейчас все данные в нём захардкожены, поэтому мы не можем использовать этот метод в последних двух тестах, например здесь:
$("#user-name").setValue(faker.name().username());
$("#password").setValue(user.password);
$("#login-button").click();
Как улучшить этот метод?
При авторизации мы используем либо значение по умолчанию, либо значение, сгенерированное Faker'ом. Может быть, мы могли бы как-то указать нашему методу, какое из них использовать? Посмотрим, что из этого получится:
@Step
public void authorize(Boolean username, Boolean password) {
String trueUsername = "standard_user";
String truePassword = "secret_sauce";
if (username && password) {
inputUsername.setValue(trueUsername);
inputPassword.setValue(truePassword);
buttonLogin.click();
checkUserAuthorized();
} else if (username) {
inputUsername.setValue(trueUsername);
inputPassword.setValue(faker.internet().password());
buttonLogin.click();
checkUserNotAuthorized();
} else if (password) {
inputUsername.setValue(faker.name().username());
inputPassword.setValue(truePassword);
buttonLogin.click();
checkUserNotAuthorized();
} else {
inputUsername.setValue(faker.name().username());
inputPassword.setValue(faker.internet().password());
buttonLogin.click();
checkUserNotAuthorized();
}
}
Отбой, отбой!!!

Вышло ещё хуже. Условная логика в тестах — это плохой сигнал, она делает их гораздо сложнее для чтения.
Мало того, мы даже не достигли того, чего хотели: сделать шаг более гибким. Мы всё ещё не можем использовать его для теста shouldNotAuthorizeUserWithEmptyAndBlankInputs
.
Есть ещё одна проблема. Вот как выглядит наш шаг, когда мы его вызываем в тесте: authorize(true, false)
.
Что значат эти "true" и "false"? К чему они относятся? В этом можно разобраться, только заглянув внутрь шага — т.е. опять-таки страдает читаемость и анализ сбоев становится медленнее.
Проблема в том, что наш шаг слишком много знает. Вместо того, чтобы указывать параметры напрямую в шаге, попробуем передать их извне:
public void authorize(String username, String password) {
inputUsername.setValue(username);
inputPassword.setValue(password);
buttonLogin.click();
}
Теперь мы можем использовать этот шаг при любом входе в системе, а его вызов будет легко читаться: authorize(username, password)
.
Параметризация
Тест shouldNotAuthorizeUserWithInvalidPassword()
, тест shouldNotAuthorizeUserWithInvalidUsername()
и один из случаев d shouldNotAuthorizeUserWithEmptyAndBlankInputs()
практически идентичны. Давайте параметризуем их:
@ParameterizedTest(name = "{0}")
@MethodSource("invalidCredentials")
@DisplayName("Пользователь не может авторизоваться с ")
public void shouldNotAuthorizeUserWithInvalidCredentials(String username, String password) {
authorize(username, password);
checkUserNotAuthorized();
}
private static Stream<Arguments> invalidCredentials() {
return Stream.of(
Arguments.of("неверным паролем", trueUsername, faker.internet().password()),
Arguments.of("неверным именем пользователя", faker.name().username(), truePassword),
Arguments.of("пустыми полями", " ", " ")
);
}
Преимущества параметризации очевидны — она позволяет писать и поддерживать меньше тестов. Кроме того, каждый тест получает правильное отображаемое имя, которое предоставляется с аргументами. Но с параметризацией можно и переборщить.
В тестировании, как и в программировании, нужно соблюдать равновесие между избеганием повторений и читаемостью (см. DRY и DAMP). Но, в отличие от программирования, акцент на читаемости в автотестах сильнее. А параметризованный тест с условной логикой и десятками параметров может быть сложнее для восприятия, чем несколько отдельных тестов.
Чем больше параметров, тем более абстрактным становится ваш тест, тем дальше он от проблемы, которую вы тестируете, и тем больше пространства для ошибок.
Мы знаем, что в некоторых компаниях умудряются параметризовать всё до чёртиков и запускать тесты с несколькими тысячами параметров. Каким-то неведомым образом у них это работает, но повторять их опыт мы не рекомендуем.
Несмотря на эти оговорки, в нашем случае параметризацию определенно стоит провести, и вот почему. Все три теста, которые мы параметризуем, однородны: они проверяют пограничные случаи одного конкретного метода. Благодаря этому для параметризованного теста легко придумать осмысленное имя.
Сокращение теста
Только что мы объединили несколько тестов в один, а теперь займёмся обратным. Вот как сейчас выглядит последний тест из нашего примера:
@Test
public void shouldNotAuthorizeUserWithEmptyAndBlankInputs() {
openAuthorizationPage();
$("#login-button").click();
checkUserNotAuthorized();
$("#user-name").setValue(" ");
$("#password").setValue(" ");
$("#login-button").click();
checkUserNotAuthorized();
$("#user-name").clear();
$("#password").clear();
$("#login-button").click();
checkUserNotAuthorized();
}
Тесты должны быть атомарными: если падает один, это не должно никак затрагивать остальные. Так легче локализовать проблему.
Если в одном тесте несколько проверок, он перестаёт быть атомарным, и становится более нестабильным: каждая из проверок может быть нестабильной, и достаточно ошибки в одной из них, чтобы упал весь тест. Любой запрос на авторизацию может дать сбой: например, система ввода может оказаться занятой. В этом случае все следующие проверки упадут независимо от того, что происходит в тестируемых ими системах.
Нам необходимо:
минимизировать след каждого теста
сделать тесты независимыми друг от друга
От одной из проверок мы уже избавились благодаря параметризации в прошлом разделе. Другая проверка просто избыточна: выполнить .clear()
и затем войти в систему равнозначно тому, чтобы просто войти в систему. В итоге весь длинный тест можно свести к двум строчкам:
@Test
public void shouldNotAuthorizeUserWithEmptyInputs() {
buttonLogin.click();
checkUserNotAuthorized();
}
Удаление класса
В нашем примере есть класс TestUser
:
public class TestUser {
Faker faker = new Faker();
String username;
String password;
TestUser() {
this.username = faker.name().username();
this.password = faker.internet().password();
}
}
Он, конечно, красивый, но в исходном примере используется всего лишь дважды. Больше того, оба этих случая находятся в тестах, которые мы параметризовали, так что в итоге мы используем его только один раз.
Лишние абстракции загромождают код и делает его менее читаемым. Можно возразить, что класс TestUser
нам понадобиться в будущем. Вот мы и создадим его тогда, когда он понадобится.
Излишне готовиться к будущему — не всегда правильная стратегия. Одно дело — модульная и масштабируемая архитектура, другое — добавлять код просто потому что он может понадобиться. Будем стремиться к простоте. Из-за акцента на читаемости этот принцип особенно важен для кода автотестов.
Проблемы взаимодействия с веб-страницами
Перейдём теперь к проблемам, специфичным для тестирования веб-страниц.
Поиск по ID и data атрибутам
Взглянем на селекторы в наших тестах:
@Step
public void checkUserAuthorized() {
$(".app_logo").shouldBe(Condition.visible);
}
В идеале в селекторах стоит использовать ID или атрибуты data-*, а не классы: они гораздо более точные, поэтому багов будет меньше. Перепишем тест так:
@Step
public void checkUserAuthorized() {
$("[data-test='secondary-header']").shouldBe(Condition.visible);
}
Однозначные проверки
Взглянем на следующую проверку:
@Step
public void checkUserNotAuthorized() {
$(".login_logo").shouldBe(Condition.visible);
}
Действительно, если логотип входа виден, это может означать, что авторизация была отклонена. Но это также может означать, что следующая страница просто слишком долго загружалась.
Попробуем сделать проверку более однозначной:
@Step
public void checkUserNotAuthorized() {
$(".login_logo").shouldBe(Condition.visible);
$("#login_button_container").shouldBe(Condition.visible);
inputUsername.shouldBe(Condition.visible);
inputPassword.shouldBe(Condition.visible);
}
Один из селекторов использует ID — это здорово. Но в целом код всё еще плохой. В сущности, мы компенсировали одну ненадёжную проверку ещё трёмя ненадежными проверками. Поэтому:
при падении теста мы не можем быть уверены, что это "настоящий" сбой, и на подтверждение придётся тратить дополнительное время
чем больше строк — тем больше места для неисправностей в тесте
тест становится менее читаемым
В реальности нам нужны только две проверки:
изменился ли URL
открылась ли соответствующая страница
Напишем на основе них шаги, удостоверяющие, что пользователь авторизован или не авторизован:
@Step("Проверить, что страница товаров открыта")
public void checkUserAuthorized() {
webdriver().shouldHave(url(Configuration.baseUrl + "/inventory.html"));
$("[data-test='secondary-header']").shouldBe(Condition.visible);
}
@Step("Проверить, что пользователь не был перенаправлен на страницу товаров")
public void checkUserNotAuthorized() {
webdriver().shouldHave(url(Configuration.baseUrl + "/"));
$("#login_button_container").shouldBe(Condition.visible);
}
Время загрузки
В нашем коде есть ещё одно место, где время загрузки может привести к ошибке в тесте:
@Step
public void openAuthorizationPage() {
// URL страницы уже в Configuration.baseUrl
open("");
}
Здесь нет уверенности, что страница загрузилась, поэтому в конце нужно добавить проверку:
@Step("Открыть страницу логина")
public void openAuthorizationPage() {
open("");
inputUsername.shouldBe(Condition.visible);
}
Тесты должны содержать проверки
Наконец, у нас есть тест, где мы просто выполняем действие и ничего не проверяем:
Такие тесты, где мы просто хотим "убедиться, что оно работает", бессмысленны. Тест не делает то, что заявлено в его названии: мы не знаем, был ли пользователь на самом деле авторизован, так что тест не проверяет даже основной путь. Добавим в конец теста проверку checkUserAuthorized()
. С учётом прочих изменений, о которых мы говорили выше, новый тест будет выглядеть так:
@Test
public void shouldAuthorizeUserWithValidCredentials() {
authorize(trueUsername, truePassword);
checkUserAuthorized();
}
Финальная версия
import com.codeborne.selenide.Condition;
import com.codeborne.selenide.Configuration;
import com.codeborne.selenide.SelenideElement;
import com.github.javafaker.Faker;
import io.qameta.allure.Step;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static com.codeborne.selenide.Selenide.*;
import static com.codeborne.selenide.WebDriverConditions.url;
public class GoodE2E {
static Faker faker = new Faker();
SelenideElement inputUsername = $("#user-name");
SelenideElement inputPassword = $("#password");
SelenideElement buttonLogin = $("#login-button");
static String trueUsername = "standard_user";
static String truePassword = "secret_sauce";
@BeforeEach
public void setUp() {
Configuration.baseUrl = "https://www.saucedemo.com";
openAuthorizationPage();
}
@Step("Открыть страницу логина")
public void openAuthorizationPage() {
open("");
inputUsername.shouldBe(Condition.visible);
}
@Step("Авторизоваться с учетными данными: {0}/{1}")
public void authorize(String username, String password) {
inputUsername.setValue(username);
inputPassword.setValue(password);
buttonLogin.click();
}
@Step("Проверить, что страница товаров открыта")
public void checkUserAuthorized() {
webdriver().shouldHave(url(Configuration.baseUrl + "/inventory.html"));
$("[data-test='secondary-header']").shouldBe(Condition.visible);
}
@Step("Проверить, что пользователь не был перенаправлен на страницу товаров")
public void checkUserNotAuthorized() {
webdriver().shouldHave(url(Configuration.baseUrl + "/"));
$("#login_button_container").shouldBe(Condition.visible);
}
@Test
public void shouldAuthorizeUserWithValidCredentials() {
authorize(trueUsername, truePassword);
checkUserAuthorized();
}
@ParameterizedTest(name = "{0}")
@MethodSource("invalidCredentials")
@DisplayName("Пользователь не может авторизоваться с ")
public void shouldNotAuthorizeUserWithInvalidCredentials(String username, String password) {
authorize(username, password);
checkUserNotAuthorized();
}
@Test
public void shouldNotAuthorizeUserWithEmptyInputs() {
buttonLogin.click();
checkUserNotAuthorized();
}
private static Stream<Arguments> invalidCredentials() {
return Stream.of(
Arguments.of("неверным паролем", trueUsername, faker.internet().password()),
Arguments.of("неверным именем пользователя", faker.name().username(), truePassword),
Arguments.of("пустыми полями", " ", " ")
);
}
}
Заключение
На первый взгляд может показаться, что некоторые из наших рекомендаций противоречат друг другу. Мы старались избегать ненужных абстракций, не жертвовать читаемость в угоду DRY, и стремиться к простоте. Несмотря на это, большая часть нашего рефакторинга свелась к избавлению от дублирования. Мы добавили больше структур, чем убрали, и наш список импортов значительно вырос. Как определить, где баланс?
Говорят, что “нет серебряной пули” — но всё же для этой проблемы есть довольно универсальная лакмусовая бумажка. Взгляните на тест в изоляции от остального кода: можно ли его прочитать, не открывая ничего, что он вызывает? Насколько быстро его можно прочитать, если вы только что прочитали ещё 10 или 100 подобных тестов? Может ли его прочитать другой человек? Проделав этот эксперимент, постарайтесь облегчить работу тем, кто будет читать ваш тест.
SurdLen
Как же мне не хватало аннотации @Step в молодости.
А что делать, если имя-логин от Faker совпадет с trueUsername в один из запусков?
mikhail-lankin Автор
1. Она спасает, да
2. Вероятность совпадения небольшая, но обезопасить себя можно. Например, добавить проверку что пароль не совпадает, условно что-то такое -