Статьи

Кровь, пот и написание автоматических интеграционных тестов для сценариев отказа

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

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

Диагностика ошибки

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

1
2
org.springframework.jdbc.CannotGetJdbcConnectionException: Could not get JDBC Connection; nested exception is java.sql.SQLRecoverableException: IO Error: The Network Adapter could not establish the connection
 at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:80)

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

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

Исправление ошибки в неправильном пути

Самый простой способ исправить эту ошибку (и к которой я часто прибегал в прошлом), был бы для Google «восстановление после сбоя базы данных», что, вероятно, привело бы меня к потоку переполнения стека, который отвечает на мой вопрос. Я бы тогда «скопировал и вставил» в предоставленный ответ и выдвинул код для тестирования.

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

Исправление ошибки в правильном направлении

Так что, как это часто бывает, выполнение «правильного пути» часто означает значительные затраты времени, загруженные шрифтами, и эта пословица здесь определенно верна.

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

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

Решение 1: макетировать все

Моя первая попытка написать тест для этой проблемы — попытаться «высмеять все». Несмотря на то, что Mockito и другие фреймворки довольно мощные и становятся все более простыми в использовании, после обдумывания этого решения я быстро пришел к выводу, что у меня никогда не будет уверенности в том, что я не буду тестировать что-либо, кроме фальшивок написал.

Получение «зеленого» результата не увеличило бы мою уверенность в правильности моего кода, и весь смысл в написании автоматизированных тестов, в первую очередь! На другой подход.

Решение 2. Используйте базу данных в памяти

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

Хотя в конечном итоге этот подход не оправдывает себя, затраченное время не теряется впустую, я немного больше узнал о H2. Одним из преимуществ правильного поведения (хотя зачастую и болезненно) является то, что вы многому учитесь. Полученные знания могут быть бесполезными в то время, но могут оказаться полезными позже.

Преимущества использования базы данных в памяти

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

  • Скорость: запуск и остановка H2 довольно быстрая, менее секунды. Так что, хотя я немного медленнее, чем использование имитаций, мои тесты все равно были бы достаточно быстрыми.
  • Переносимость: H2 может работать полностью из импортированного jar, поэтому другие разработчики могут просто снять мой код и запустить все тесты, не выполняя никаких дополнительных шагов.

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

Написание теста

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

Ниже приведен исходный тестовый пример, который я создал для проверки моей проблемы с PROD:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RunWith(SpringRunner.class)
@SpringBootTest(classes = DataSourceConfig.class, properties = {"datasource.driver=org.h2.Driver",
"datasource.url=jdbc:h2:mem:;MODE=ORACLE", "datasource.user=test", "datasource.password=test" })
public class ITDatabaseFailureAndRecovery {
 
   @Autowired
   private DataSource dataSource;
 
 
   @Test
   public void test() throws SQLException {
      Connection conn = DataSourceUtils.getConnection(dataSource);
      conn.createStatement().executeQuery("SELECT 1 FROM dual");
      ResultSet rs = conn.createStatement().executeQuery("SELECT 1 FROM dual");
      assertTrue(rs.next());
      assertEquals(1, rs.getLong(1));
      conn.createStatement().execute("SHUTDOWN");
      DataSourceUtils.releaseConnection(conn, dataSource);
      conn = DataSourceUtils.getConnection(dataSource);
      rs = conn.createStatement().executeQuery("SELECT 1 FROM dual");
      assertTrue(rs.next());
      assertEquals(1, rs.getLong(1));
   }
}

Сначала я чувствовал, что на правильном пути с этим решением. Возникает вопрос, как запустить резервное копирование сервера H2 (по одной проблеме за раз!). Но когда я запускаю тест, он дает сбой и выдает ошибку, аналогичную той, с которой сталкивается мой сервис в PROD:

1
org.h2.jdbc.JdbcSQLException: Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-192]

Однако, если я изменю свой контрольный пример и просто попытаюсь установить второе соединение с базой данных:

1
conn = DataSourceUtils.getConnection(dataSource);

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

Почему это решение не сработало

Таким образом, использование H2 не будет работать. На самом деле я потратил немного больше времени, пытаясь заставить H2 работать, чем то, что можно было бы предположить выше. Попытки устранения неполадок включены; подключение к экземпляру сервера H2 на основе файлов вместо удаленного сервера H2; Я даже наткнулся на класс H2 Server , который раньше решал проблему с отключением / запуском сервера.

Ни одна из этих попыток не сработала очевидно. Основная проблема с H2, по крайней мере для этого тестового случая, заключается в том, что попытка подключиться к базе данных приведет к ее запуску, если она в данный момент не работает. Как показывает мой первоначальный тест, есть небольшая задержка, но, очевидно, это создает фундаментальную проблему. В PROD, когда моя служба пытается подключиться к базе данных, она не вызывает запуск базы данных (независимо от того, сколько раз я пытался подключиться к ней). Логи моей службы, безусловно, могут подтвердить этот факт. Итак, к другому подходу.

Решение 3. Подключение к локальной базе данных

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

Проблемы с этим решением

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

  • Снижение мобильности: если другой разработчик захочет запустить этот тест, ему нужно будет загрузить и установить базу данных на свой локальный компьютер. Она также должна убедиться, что ее данные конфигурации соответствуют ожиданиям теста. Это трудоемкая задача, которая может привести к как минимум некоторому количеству внеплановых знаний.
  • Медленнее: в целом мой тест все еще не слишком медленный, но для запуска, выключения и повторного запуска требуется несколько секунд, даже для локальной базы данных. Хотя несколько секунд звучат не так много, время может сложиться с достаточным количеством тестов. Это серьезная проблема, поскольку интеграционные тесты могут занимать больше времени (подробнее об этом позже), но чем быстрее интеграционные тесты, тем чаще они могут выполняться.
  • Организационные споры: чтобы запустить этот тест на сервере сборки, мне теперь нужно будет поработать с моей уже перегруженной командой DevOps, чтобы настроить базу данных в окне сборки. Даже если бы оперативная команда не была перегружена, я просто хотел бы избежать этого, если это возможно, поскольку это всего лишь еще один шаг.
  • Лицензирование: в моем примере кода я использую MySQL в качестве тестовой реализации базы данных. Однако для моего клиента я подключался к базе данных Oracle. Oracle действительно предлагает Oracle Express Edition (XE) бесплатно, однако это идет с оговорками. Одним из таких условий является то, что два экземпляра Oracle XE не могут работать одновременно. Помимо конкретного случая Oracle XE, лицензирование может стать проблемой, когда речь идет о подключении к конкретным предложениям продуктов, о чем следует помнить.

Успех! … В заключение

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void testServiceRecoveryFromDatabaseOutage() throws SQLException, InterruptedException, IOException {
   Connection conn = null;
   conn = DataSourceUtils.getConnection(datasource);
   assertTrue(conn.createStatement().execute("SELECT 1"));
   DataSourceUtils.releaseConnection(conn, datasource);
   LOGGER.debug("STOPPING DB");
   Runtime.getRuntime().exec("/usr/local/mysql/support-files/mysql.server stop").waitFor();
   LOGGER.debug("DB STOPPED");
   try {
      conn = DataSourceUtils.getConnection(datasource);
      conn.createStatement().execute("SELECT 1");
      fail("Database is down at this point, call should fail");
    } catch (Exception e) {
       LOGGER.debug("EXPECTED CONNECTION FAILURE");
    }
    LOGGER.debug("STARTING DB");
    Runtime.getRuntime().exec("/usr/local/mysql/support-files/mysql.server start").waitFor();
    LOGGER.debug("DB STARTED");
    conn = DataSourceUtils.getConnection(datasource);
    assertTrue(conn.createStatement().execute("SELECT 1"));
    DataSourceUtils.releaseConnection(conn, datasource);
}

Полный код здесь: https://github.com/wkorando/integration-test-example/blob/master/src/test/java/com/integration/test/example/ITDatabaseFailureAndRecovery.java

Исправление

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

Первоначально источник данных, который я настроил в конфигурации моей службы, выглядел так:

1
2
3
4
5
6
7
8
9
@Bean
public DataSource dataSource() {
   org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
   dataSource.setDriverClassName(env.getRequiredProperty("datasource.driver"));
   dataSource.setUrl(env.getRequiredProperty("datasource.url"));
   dataSource.setUsername(env.getRequiredProperty("datasource.user"));
   dataSource.setPassword(env.getRequiredProperty("datasource.password"));
   return dataSource;
}

Основная проблема, с которой столкнулся мой сервис, заключается в том, что когда соединение из пула соединений DataSource не удалось подключиться к базе данных, оно стало «плохим». Следующая проблема заключалась в том, что моя реализация DataSource не удаляла эти «плохие» соединения из пула соединений. Он просто пытался использовать их снова и снова.

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

Наконец (не обязательно, но полезно для тестирования), по умолчанию моя реализация DataSource будет проверять соединение только каждые 30 секунд. Однако было бы неплохо, чтобы мой тест запустился менее чем за 30 секунд. В конечном счете, продолжительность этого периода не имеет особого смысла, поэтому я добавил интервал проверки, предоставляемый файлом свойств.

Вот как выглядит мой обновленный источник данных:

01
02
03
04
05
06
07
08
09
10
11
12
@Bean
public DataSource dataSource() {
   org.apache.tomcat.jdbc.pool.DataSource dataSource = new org.apache.tomcat.jdbc.pool.DataSource();
   dataSource.setDriverClassName(env.getRequiredProperty("datasource.driver"));
   dataSource.setUrl(env.getRequiredProperty("datasource.url"));
   dataSource.setUsername(env.getRequiredProperty("datasource.user"));
   dataSource.setPassword(env.getRequiredProperty("datasource.password"));
   dataSource.setValidationQuery("SELECT 1");
   dataSource.setTestOnBorrow(true);
   dataSource.setValidationInterval(env.getRequiredProperty("datasource.validation.interval"));
   return dataSource;
}

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

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

Автоматизация теста

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

Пользователи Maven: надеюсь, вы уже знакомы с плагином surefire . Или, по крайней мере, надеюсь, что ваша команда DevOps уже настроила ваш родительский pom, так что когда проект строится на вашем сервере сборки, все те модульные тесты, которые вы потратили время на запись, запускаются с каждым коммитом.

Эта статья, однако, не о написании модульных тестов, а о написании интеграционных тестов . Набор интеграционных тестов обычно занимает намного больше времени (иногда часов), чем набор модульных тестов (который должен занимать не более 5-10 минут). Интеграционные тесты также обычно более подвержены волатильности. Хотя интеграционный тест, который я написал в этой статье, должен быть стабильным — если он ломается, это должно вызывать беспокойство — при подключении к базе данных разработки вы не всегда можете быть на 100% уверены, что база данных будет доступна или что ваши тестовые данные будет правильным или даже настоящим. Таким образом, неудачный интеграционный тест не обязательно означает, что код неверен.

К счастью, ребята из Maven уже обратились к этому, и это с помощью отказоустойчивого плагина . В то время как плагин surefire по умолчанию будет искать классы, которые были предварительно или пост-исправлены с помощью Test , плагин failsafe будет искать классы, которые были предварительно или пост-исправлены с помощью IT (Integration Test). Как и все плагины Maven, вы можете настроить, в каких целях плагин должен выполнять. Это дает вам возможность запускать свои модульные тесты с каждым коммитом кода, а интеграционные тесты — только во время ночной сборки. Это также может предотвратить сценарий, в котором необходимо развернуть оперативное исправление, но ресурс, от которого зависит интеграционный тест, отсутствует.

Последние мысли

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

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

Код, который я использовал в этой статье, можно найти здесь: https://github.com/wkorando/integration-test-example .