Статьи

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Тестируемый класс
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. В тестовой среде я создам класс, который расширяет тестируемый класс.
    Этот класс переопределит эти методы и вернет поддельные (поддельные) сервисы.

Код решения


Тестируемый класс
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();
}

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

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 — Статистические тесты для случайного ввода

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

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

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

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.

Код решения

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);
}

Вывод

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