(Статья - результат совместной работы с Натальей Поляковой)

«Запахи» в тестах — это признаки антипаттернов; мы уже писали про то, как их распознавать в юнит-тестах и e2e-тестах. Хотя причины появления запахов тестов могут быть самыми разными, сегодня мы хотим рассмотреть одну повторяющуюся тему — структуру команды, а более конкретно — проблемы в общении у тестировщиков с другими командами. 

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

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

  • знание всех технических нюансов и слабых мест тестируемой системы (SUT), известное разработчикам и ручным тестировщикам;

  • теория тестирования, известная тестировщикам;

  • реализация тестов на конкретном языке и фреймворке, с которыми знакомы инженеры по автоматизации (SDET).

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

Начнём с общего вопроса: как отличить плохой тестовый код от хорошего?

Критерий хорошего теста

Тесты используются иначе, чем продакшн-код.

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

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

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

Проверить, соответствует ли код этим критериям, можно вот как: прочитайте метод теста в IDE отдельно от всего остального кода (можно даже увеличить масштаб, чтобы ничего другого не было видно). Можно ли разобраться, что делает тест, не заглядывая во вспомогательные функции? Сможет ли разобраться кто-то другой? Если да — вы уже сэкономили кучу времени на анализ и поддержку тестов.

Какие наиболее частые причины мешают писать тесты, соответствующие этим критериям?

Проблемы в продакшен-коде

Многие запахи тестов указывают на проблемы в продакшен-коде — избыточную связанность, низкую модульность и плохое разделение ответственности.

Например:

  • “Жадный тест” — тест проверяет слишком много логики;

  • “Рулетка ассёртов” — много ассёртов, поэтому когда тест падает, непонятно, что именно сработало;

  • “Гигант” — просто огромный тест.

  • Избыточная подготовка” — например, чтобы запустить тест, нужно поднять три сервиса и базу данных. Или: чтобы проверить строку в URL, надо создать Jenkins-инстанс.

  • Непрямое тестирование” — что нужное место для тестирования недоступно, и к нему приходится пробираться окольными путями. Например, когда переменная с IP спрятана внутри функции, и достать её оттуда нельзя.

Обычно эти запахи говорят о том, что тестируемая система берёт на себя слишком многое — “объект-бог” (или, иначе говоря, у кода большая степень связанности). Согласно одному исследованию, запах «жадный тест» статистически связан с более частыми изменениями и дефектами в продакшен-коде.

В другой статье (написанной совместно с Максимом Степановым) мы рефакторили сервис на Python, который:

  1. проверяет IP пользователя,

  2. определяет город,

  3. получает погоду,

  4. сравнивает с предыдущими данными,

  5. проверяет, сколько времени прошло,

  6. записывает новое измерение, если прошло достаточно времени,

  7. выводит результат в консоль.

Это — пример антипаттерна:

def local_weather():
    # Получаем IP
    url = "https://api64.ipify.org?format=json"
    response = requests.get(url).json()
    ip_address = response["ip"]

    # Определяем город с помощью IP
    url = f"https://ipinfo.io/{ip_address}/json"
    response = requests.get(url).json()
    city = response["city"]

    with open("secrets.json", "r", encoding="utf-8") as file:
        owm_api_key = json.load(file)["openweathermap.org"]

    # Получаем погоду из сервиса 
    url = (
        "https://api.openweathermap.org/data/2.5/weather?q={0}&"
        "units=metric&lang=ru&appid={1}"
    ).format(city, owm_api_key)
    weather_data = requests.get(url).json()
    temperature = weather_data["main"]["temp"]
    temperature_feels = weather_data["main"]["feels_like"]

    # Если уже сделаны прошлые измерения, сравниваем их с текущими
    has_previous = False
    history = {}
    history_path = Path("history.json")
    if history_path.exists():
        with open(history_path, "r", encoding="utf-8") as file:
            history = json.load(file)
        record = history.get(city)
        if record is not None:
            has_previous = True
            last_date = datetime.fromisoformat(record["when"])
            last_temp = record["temp"]
            last_feels = record["feels"]
            diff = temperature - last_temp
            diff_feels = temperature_feels - last_feels

    # Если прошло достаточно времени с прошлого измерения, записываем результат
    now = datetime.now()
    if not has_previous or (now - last_date) > timedelta(hours=6):
        record = {
            "when": datetime.now().isoformat(),
            "temp": temperature,
            "feels": temperature_feels
        }
        history[city] = record
        with open(history_path, "w", encoding="utf-8") as file:
            json.dump(history, file)

    # Выводим результат
    msg = (
        f"Temperature in {city}: {temperature:.0f} °C\n"
        f"Feels like {temperature_feels:.0f} °C"
    )
    if has_previous:
        formatted_date = last_date.strftime("%c")
        msg += (
            f"\nLast measurement taken on {formatted_date}\n"
            f"Difference since then: {diff:.0f} (feels {diff_feels:.0f})"
        )
    print(msg)

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

Все эти запахи указывают на то, что код слишком монолитен. Что делать?

Рефакторить его:

  • Разделить ответственность и логику ввода-вывода;

  • Использовать внедрение зависимостей, чтобы подставлять тестовые двойники.

  • Если система по природе сложно тестируема (например, UI), сделайте её максимально тонкой и вынесите логику наружу — оставьте Humble Object.

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

Работаем вместе
Работаем вместе

Недостаток внимания к тестовому коду

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

Если же идти по пути наименьшего сопротивления и «срезать углы», у тестов появляются запахи:

  • тесты с именами test0, test1, test2;

  • тесты без ассёртов («лишь бы не упал»);

  • Захардкоженные значения вместо переменных с понятными именами.

Вот пример антипаттерна из другой нашей статьи про запахи тестов на JUnit:

@Test  
void test() {  
    String a = "John";  

    String b = hello(a);  

    assert(b).matches("Hello John!");  
}

Написано явно впопыхах — что такое a, b, test? Надо думать, разбираться; в итоге усилия, сэкономленные на написании, возвращаются многократно при прочтении.

Сделаем код понятнее:

@Test  
void shouldReturnHelloPhrase() {  
    String name = "John";  

    String result = hello(name);  

    assert(result).contains("Hello " + name + "!");  
}

Второй вариант читается лучше и проще поддерживается.

Ещё одна частая ошибка в тестах — лишние подробности. Сравните два теста на JUnit + Selenide. Первый:

@Test
public void shouldAuthorizeUserWithValidCredentials() {
    TestUser user = new TestUser();

    openAuthorizationPage();

    $("#user-name").setValue(user.username);
    $("#password").setValue(user.password());
    $("#login-button").click();

    checkUserAuthorized();
}

Второй:

@Test
public void shouldAuthorizeUserWithValidCredentials() {
    authorize(trueUsername, truePassword);

    checkUserAuthorized();
}

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

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

Недостаток знаний тестовой теории

Тестовая теория советует много полезного, как-то:

  • Каждый тест должен проверять что-то одно;

  • Тесты не должны зависеть друг от друга;

  • По возможности, проверять вещи стоит на более низких уровнях (юнит или API, а не E2E);

  • Проверки стоит дублировать на разных уровнях.

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

Частая ошибка, которую можно увидеть у разработчиков с небольшим опытом E2E-тестирования (или вообще любого тестирования), — это размещение всего в E2E-тестах и создание очень длинных тестов, которые проверяют тысячу мелких деталей одновременно. Это приводит к появлению описанных выше запахов «Гигант» и «Рулетка ассёртов».

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

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

Прямой перевод в автоматизированные тесты

Мы уже упомянули о проблеме с переполнением уровня E2E. Остановимся на одной из возможных причин этого “запаха”.

Конечно, без UI тестов не обойтись, и бывают случаи, когда этот слой должен быть тяжёлым. Но обычно на нём достаточно протестировать только “счастливые пути”. Изучение граничных случаев (которых по определению всегда больше) лучше проводить на более низких уровнях, где это намного дешевле.

У такого превращения пирамиды тестирования в “рожок мороженого” могут быть организационные причины, может также иметь организационные причины.

Конкретно, такой “запах” возникает часто на «фабриках автоматизации», когда SDET и ручной тестировщик живут в двух параллельных мирах: один знает, как тестировать, другой знает, что тестировать. Тестовые случаи бездумно переводятся в автоматизированные тесты, которые все оказываются E2E, поскольку ручное тестирование (с которого копируют автоматизаторы) проводится только через интерфейс. В результате получаются дорогие и нестабильные тесты.

Есть ещё одна причина, по которой может происходить переполнение уровня E2E. E2E-тесты — это те, которые напрямую соответствуют потребностям и требованиям пользователя. Что, как говорят разработчики из Google, делает их особенно привлекательными для лиц, принимающих решения («Сосредоточьтесь на пользователе, и всё остальное приложится»).

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

Выводы

Источников проблем у тестового кода много, но мы здесь сконцентрировались на одной теме: качество должно быть заботой всех ролей в команде, а не только инженеров QA. Исследования показали, что «наличие автоматизированных тестов, созданных и поддерживаемых главным образом QA или внешней организацией, не коррелирует с производительностью IT».

Ситуация “организационных колодцев”, когда команды не слушают друг друга, приводит ко многим запахам кода. Некоторые запахи указывают на плохую тестируемость основного кода, что, в свою очередь, означает, что тестирование и разработка находятся слишком далеко друг от друга.

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

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

Мораль: здоровые тесты без “запахов” — показатель не только хорошей работы тестировщиков, но и правильно налаженной коммуникации в команде.

Комментарии (0)