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

«Запахи» в тестах — это полезные сигналы, которые важно уметь распознавать, чтобы писать удобные и легко поддерживаемые тесты. Мы уже писали про "запахи" в E2E-тестах; сейчас же рассмотрим распространённые ошибки, которые возникают при написании модульных тестов.

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

В книге Джерарда Месароша о паттернах в xUnit есть полезные главы о «запахах тестов», и в интернете можно найти много других полезных материалов по этой теме. Нам же показалось интересным подойти к этой проблеме не со стороны теории, а со стороны практики: какие частые ошибки можно встретить в тестах, как их исправлять, и почему именно тесты нужно писать так, а не иначе?

Мы разберём всё это на примере: напишем один модульный тест на JUnit, и по ходу дела будем исправлять возникающие ошибки. Код примера доступен на GitHub.

Эволюция одного теста

Нам нужно протестировать простую функцию:

public String hello(String name) {  
    return "Hello " + name + "!";  
}

Начнём писать для нее модульный тест:

@Test
void test() {

}

Стоп. Наш код уже «пахнет».

1. Неинформативное название

Естественно, гораздо проще написать test, test1, test2, чем придумать осмысленное название. К тому же это гораздо короче!

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

Тест, сообщающий намерение автора читателю
Тест, сообщающий намерение автора читателю

Может быть, стоило назвать тест testHello, раз он тестирует функцию hello? Нет, потому что мы тестируем не метод, а поведение. Поэтому хорошим названием было бы shouldReturnHelloPhrase:

@Test  
void shouldReturnHelloPhrase() {  
    assert(hello("John")).matches("Hello John!");  
}

Никто (кроме фреймворка) не будет вызывать тестовый метод напрямую, поэтому не  страшно, что название длинное, оно не будет перегружать другой код. Главное, что оно осмысленное и содержательное (descriptive and meaningful phrase, DAMP).

2. Отсутствие arrange-act-assert

С названием разобрались. Следующая проблема: у нас слишком много кода втиснуто в одну строку. Разделим его на три части: подготовку, действие и проверку (arrange-act-assert).

arrange - act - assert
arrange - act - assert

Получим следующее:

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

    String b = hello(“John”);  

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

В BDD принято использовать паттерн Given-When-Then — в данном случае это те же arrange-act-assert.

3. Плохие имена переменных и отсутствие повторного использования переменных

Но тест по-прежнему выглядит так, как будто бы его написали впопыхах. Что такое a? Что такое b? Сразу не очевидно. А теперь представьте, что в тестовом прогоне несколько десятков сбоев (что вполне реально, если вы запускаете несколько тысяч тестов). И в каждом случае надо разобраться, что это за a и b.

Итак — названия переменных, как и названия тестов, нужно выбирать осмысленно.

Есть ещё одна проблема: все значения у нас захардкожены. Некоторые вещи прописывать напрямую можн — но только если они не связаны с другими прямо прописанными вещами.

Это значит, что при чтении теста связи между данными должны быть очевидны. "John" в переменной a — это то же самое, что "John" в утверждении? При чтении мы не должны тратить время на такие вопросы.

С учётом всех этих соображений, перепишем тест вот так:

@Test  
void shouldReturnHelloPhrase() {  

    String name = "John";  

    String result = hello(name);  
    String expectedResult = "Hello " + name + "!";

    assert(result).contains(expectedResult);  
}

4. Эффект пестицида

Автотесты хороши тем, что их легко прогонять снова и снова — но со временем их эффективность падает, потому что тестируешь одно и то же. Это явление называется парадоксом пестицида (термин, введенный Борисом Бейзером еще в 1980-х): ошибки “вырабатывают устойчивость” к тому, чем вы их убиваете.

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

@Test  
void shouldReturnHelloPhrase() {  
    Faker faker = new Faker();  
    String name = faker.name().firstName();  

    String result = hello(name);  
    String expectedResult = "Hello " + name + "!";

    assert(result).contains(expectedResult);  
}

Хорошо, что мы изменили имя на переменную на предыдущем шаге — теперь нам не нужно просматривать тест и выискивать все "John".

5. Неинформативные сообщения об ошибках

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

java.lang.AssertionError
    at org.example.UnitTests.shouldReturnHelloPhrase(UnitTests.java:58)

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

К счастью, в JUnit есть свои проверки в классе Assertions. Используют их так:

@Test  
void shouldReturnHelloPhrase4() {  
    Faker faker = new Faker();  
    String name = faker.name().firstName();  

    String result = hello(name);  
    String expectedResult = "Hello " + name + "";

    Assertions.assertEquals(  
            result,  
            expectedResult
    );  

}

Сообщение об ошибке теперь гораздо полезнее:

Expected :Hello Tanja!
Actual   :Hello Tanja

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

Чему мы научились

Итак, наш unit-тест стал гораздо лучше. Какие уроки можно извлечь из этого рефакторинга?

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

Захардкодить данные, скопировать и вставить код, назвать тест "test" + имя метода немного проще в краткосрочной перспективе, но тестовую базу из-за таких практик становится гораздо труднее поддерживать.

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

А с какими ошибками в тестах чаще всего сталкиваетесь вы? Поделитесь, и пусть ваш код всегда благоухает!

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


  1. Djaler
    07.10.2025 17:50

    И в итоге получили тест, в котором повторяется имплементация тестируемого кода. Зато coverage 100%, ура)


  1. KaryamaGavi
    07.10.2025 17:50

    Вот за такое надо "бить по рукам"

    String expectedResult = "Hello " + name + "!";

    Это бизнес логика утекла в тест. Так делать не надо