Статьи

Работа с устаревшим тестовым кодом

Устаревший код и запах тестами

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

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

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

Типы испытаний

(или слои)

Существует несколько типов или слоев тестов.

  • Модульные тесты
    Модульный тест должен быть простым для описания и понимания. Эти тесты должны выполняться быстро . Они должны проверить одну вещь. Одна единица (метод?) Работы.
  • Интеграционные тесты
    Интеграционные тесты более расплывчаты по определению. Какие модули они проверяют? Интеграция нескольких модулей вместе? Проводка инжектора зависимостей? Тест с использованием реальной БД?
  • Поведенческие тесты
    Эти тесты будут проверять функции. Они могут быть интерфейсом между PM / PO и командой разработчиков.
  • End2End / Acceptance / Staging / Functional
    Тесты высокого уровня. Может работать в производственной или производственной среде.

Сложность тестов

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

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

Спорадически провальные тесты

Есть много причин для того, чтобы тест был «проблематичным». Одним из худших является тест, который иногда не проходит и обычно проходит. Команда игнорирует письма CI. Это создает шум в системе. Вы никогда не можете быть уверены, есть ли ошибка или что-то сломано, или это ложная тревога. В конце концов мы отключим CI, потому что «он не работает и не стоит времени».

Интеграционный тест и ложная тревога

Любой тип теста является предметом ложных срабатываний, если мы не следуем основным правилам. Чем выше уровень теста, тем больше вероятность ложных срабатываний. В интеграционных тестах выше вероятность ложных срабатываний из-за проблем с внешними ресурсами: нет подключения к Интернету, нет соединения с БД, случайная ошибка и многое другое.

Наша тестовая среда

Наша система «квази-наследие». Это не совсем наследство, потому что у него есть тесты. Эти тесты даже имеют хорошее покрытие. Это наследие из-за того, как он (не) структурирован и как построены тесты. Раньше его охватывали только интеграционные тесты. В последние несколько месяцев мы начали внедрять юнит-тесты. Особенно на новый код и новые функции.

Все наши интеграционные тесты наследуются от BaseTest , который наследует SpringJ AbstractJUnit4SpringContextTests . Контекст теста связывает все. Около 95% производственного кода. Это требует времени, но еще хуже, он подключается к реальным внешним ресурсам, таким как MongoDB и сервисам, которые подключаются к Интернету.

Чтобы повысить скорость тестирования, несколько недель назад я изменил MongoDB на встроенный. Это улучшило время выполнения тестов на порядок.

Этот тип настройки делает тестирование намного сложнее. Очень сложно издеваться над сервисами. Среда не изолирована от интернета и БД и многое другое.

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

Пример 1 — Создание подключения к Интернету в конструкторе

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
private HttpClient httpClient;
private MyServiceOne myServiceOne;
private MyServiceTwo myServiceTwo;
 
public ClassUnderTest(PoolingClientConnectionManager httpConnenctionManager, int connectionTimeout, int soTimeout) {
    HttpParams httpParams = new BasicHttpParams();
    HttpConnectionParams.setConnectionTimeout(httpParams, connectionTimeout);
    HttpConnectionParams.setSoTimeout(httpParams, soTimeout);
    HttpConnectionParams.setTcpNoDelay(httpParams, true);
    httpClient = new DefaultHttpClient(httpConnenctionManager, httpParams);
 
    myServiceOne = new MyServiceOne(httpClient);
    myServiceTwo = new MyServiceTwo();
}

В тестируемом методе используется myServiceOne . И тест иногда не удался из-за проблем с подключением в этом сервисе. Другая проблема заключалась в том, что он не всегда был детерминированным (результат из Интернета) и, следовательно, не удался.

То, как написан код, не позволяет нам издеваться над сервисами.

В тестовом коде тестируемый класс вводился с использованием аннотации @Autowired .

Решение — извлечение и переопределение вызова

Идея была взята из Эффективной работы с Устаревшим Кодексом .

  1. Определить, что мне нужно исправить.
    Чтобы сделать тест детерминированным и без реального подключения к интернету, мне нужен доступ для создания сервисов.
  2. Я представлю защищенные методы, которые создают эти сервисы.
    Вместо создания сервисов в конструкторе я буду вызывать эти методы.
  3. В тестовой среде я создам класс, который расширяет тестируемый класс.
    Этот класс переопределит эти методы и вернет поддельные (поддельные) сервисы.

Код решения

Тестируемый класс

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public ClassUnderTest(PoolingClientConnectionManager httpConnenctionManager, int connectionTimeout, int soTimeout) {
    HttpParams httpParams = new BasicHttpParams();
    HttpConnectionParams.setConnectionTimeout(httpParams, connectionTimeout);
    HttpConnectionParams.setSoTimeout(httpParams, soTimeout);
    HttpConnectionParams.setTcpNoDelay(httpParams, true);
     
    this.httpClient = createHttpClient(httpConnenctionManager, httpParams);
    this.myserviceOne = createMyServiceOne(httpClient);
    this.myserviceTwo = createMyServiceTwo();
}
 
protected HttpClient createHttpClient(PoolingClientConnectionManager httpConnenctionManager, HttpParams httpParams) {
    return new DefaultHttpClient(httpConnenctionManager, httpParams);
}
 
protected MyServiceOne createMyServiceOne(HttpClient httpClient) {
    return new MyServiceOne(httpClient);
}
 
protected MyServiceTwo createMyServiceTwo() {
    return new MyServiceTwo();
}

Тестовый класс

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private MyServiceOne mockMyServiceOne = mock(MyServiceOne.class);
private MyServiceTwo mockMyServiceTwo = mock(MyServiceTwo.class);
private HttpClient mockHttpClient = mock(HttpClient.class);
 
private class ClassUnderTestForTesting extends ClassUnderTest {
 
    private ClassUnderTestForTesting(int connectionTimeout, int soTimeout) {
        super(null, connectionTimeout, soTimeout);
    }
     
    @Override
    protected HttpClient createHttpClient(PoolingClientConnectionManager httpConnenctionManager, HttpParams httpParams) {
        return mockHttpClient;
    }
 
    @Override
    protected MyServiceOne createMyServiceOne(HttpClient httpClient) {
        return mockMyServiceOne;
    }
 
    @Override
    protected MyServiceTwo createMyServiceTwo() {
        return mockMyServiceTwo;
    }
}

Теперь вместо связывания тестируемого класса я создал его в методе @Before . Он принимает другие услуги (не описанные здесь). Я получил эти сервисы, используя @Autowire.

Еще одно замечание: перед созданием специального класса для теста я запустил все интеграционные тесты этого класса, чтобы убедиться, что рефакторинг ничего не сломал. Я также перезапустил сервер локально и проверил, что все работает. Это важно сделать при работе с устаревшим кодом.

Пример 2 — Статистические тесты для случайного ввода

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

Упрощенный пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
private Random random;
 
public ClassUnderTest() {
    random = new Random();
    // more stuff
}
 
//The method is package protected so we can test it
MyPojo select(List<MyPojo> pojos) {
    // do something
    int randomSelection = random.nextInt(pojos.size());
    // do something
    return pojos.get(randomSelection);
}

Исходный тест сделал статистический анализ. Я просто объясню это, поскольку это слишком сложно и многословно, чтобы написать это. У него был цикл из 10 тысяч итераций. Каждая итерация вызывала тестируемый метод. У него была Карта, которая подсчитывала количество вхождений (возвращаемый результат) на MyPojo. Затем он проверил, был ли выбран каждый MyPojo в (10K / Number-Of-MyPojo) с некоторым отклонением 0,1.

Пример:

Скажем, у нас есть 4 экземпляра MyPojo в списке. Затем утверждение подтвердило, что каждый экземпляр был выбран между 2400 и 2600 раз (10K / 4) с отклонением 10%.

Конечно, можно ожидать, что иногда тест не удался. Увеличение отклонения только уменьшит количество ложных неудачных испытаний.

Решение — перегрузить метод

  1. Перегрузка тестируемого метода.
    В перегруженном методе добавьте параметр, который совпадает с глобальным полем.
  2. Переместите код из оригинального метода в новый.
    Убедитесь, что вы используете параметр метода, а не поле класса. Здесь могут помочь разные имена.
  3. Проверяет вновь созданный метод с помощью mock.

Код решения

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
private Random random;
 
// Nothing changed in the constructor
public ClassUnderTest() {
    random = new Random();
    // more stuff
}
 
// Overloaded method
private select(List<MyPojo> pojos) {
    return select(pojos, this.random);
}
 
//The method is package protected so we can test it
MyPojo select(List<MyPojo> pojos, boolean inRandom) {
    // do something
    int randomSelection = inRandom.nextInt(pojos.size());
    // do something
    return pojos.get(randomSelection);
}

Вывод

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