Время, когда наши приложения жили изолированно, давно прошло. В настоящее время приложения — это очень сложные звери, которые разговаривают друг с другом, используя множество API и протоколов, хранят данные в традиционных базах данных или базах данных NoSQL, отправляют сообщения и события по проводам …
Как часто вы задумывались о том, что произойдет, если, например, база данных выйдет из строя, когда ваше приложение будет активно ее запрашивать? Или какая-то конечная точка API вдруг начинает отказываться от соединения? Разве не было бы неплохо, чтобы такие несчастные случаи освещались как часть вашего набора тестов? Вот что такое инъекция ошибок и структура Byteman .
В качестве примера мы создадим реалистичное полнофункциональное приложение Spring, которое использует Hibernate / JPA для доступа к базе данных MySQL и управления клиентами. В рамках набора тестов интеграции JUnit приложения мы будем включать три вида тестовых случаев:
- магазин / найти покупателя
- сохранить клиента и попытаться запросить базу данных, когда она не работает
- тайм-аут запроса клиента и базы данных (симуляция ошибок)
Существует только два предварительных условия для запуска приложения на вашем локальном компьютере разработки:
- MySQL сервер установлен и имеет базу клиентов
- Oracle JDK установлен и переменная окружения JAVA_HOME указывает на него
При этом мы готовы к работе.
Во-первых, давайте опишем нашу модель домена, которая состоит из одного класса Customer с идентификатором и одним именем свойства . Это выглядит так просто:
package com.example.spring.domain; import java.io.Serializable; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; @Entity @Table( name = "customers" ) public class Customer implements Serializable{ private static final long serialVersionUID = 1L; @Id @GeneratedValue @Column(name = "id", unique = true, nullable = false) private long id; @Column(name = "name", nullable = false) private String name; public Customer() { } public Customer( final String name ) { this.name = name; } public long getId() { return this.id; } protected void setId( final long id ) { this.id = id; } public String getName() { return this.name; } public void setName( final String name ) { this.name = name; } }
Для простоты уровень обслуживания смешивается с уровнем доступа к данным и напрямую обращается к базе данных. Вот наша реализация CustomerService :
package com.example.spring.services; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.example.spring.domain.Customer; @Service public class CustomerService { @PersistenceContext private EntityManager entityManager; @Transactional( readOnly = true ) public Customer find( long id ) { return this.entityManager.find( Customer.class, id ); } @Transactional( readOnly = false ) public Customer create( final String name ) { final Customer customer = new Customer( name ); this.entityManager.persist(customer); return customer; } @Transactional( readOnly = false ) public void deleteAll() { this.entityManager.createQuery( "delete from Customer" ).executeUpdate(); } }
И, наконец, контекст приложения Spring, который определяет источник данных и менеджер транзакций. Небольшое замечание: поскольку мы не будем вводить классы слоя доступа к данным ( @Repository ), для того, чтобы Spring правильно выполнял преобразование исключений, мы определяем экземпляр PersistenceExceptionTranslationPostProcessor для классов обслуживания после обработки ( @Service ). Все остальное должно быть очень знакомым.
package com.example.spring.config; import java.util.Properties; import javax.sql.DataSource; import org.hibernate.dialect.MySQL5InnoDBDialect; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.vendor.Database; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import com.example.spring.services.CustomerService; @EnableTransactionManagement @Configuration @ComponentScan( basePackageClasses = CustomerService.class ) public class AppConfig { @Bean public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor() { final PersistenceExceptionTranslationPostProcessor processor = new PersistenceExceptionTranslationPostProcessor(); processor.setRepositoryAnnotationType( Service.class ); return processor; } @Bean public HibernateJpaVendorAdapter hibernateJpaVendorAdapter() { final HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter(); adapter.setDatabase( Database.MYSQL ); adapter.setShowSql( false ); return adapter; } @Bean public LocalContainerEntityManagerFactoryBean entityManager() throws Throwable { final LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean(); entityManager.setPersistenceUnitName( "customers" ); entityManager.setDataSource( dataSource() ); entityManager.setJpaVendorAdapter( hibernateJpaVendorAdapter() ); final Properties properties = new Properties(); properties.setProperty("hibernate.dialect", MySQL5InnoDBDialect.class.getName()); properties.setProperty("hibernate.hbm2ddl.auto", "create-drop" ); entityManager.setJpaProperties( properties ); return entityManager; } @Bean public DataSource dataSource() { final DriverManagerDataSource dataSource = new DriverManagerDataSource(); dataSource.setDriverClassName( com.mysql.jdbc.Driver.class.getName() ); dataSource.setUrl( "jdbc:mysql://localhost/customers?enableQueryTimeouts=true" ); dataSource.setUsername( "root" ); dataSource.setPassword( "" ); return dataSource; } @Bean public PlatformTransactionManager transactionManager() throws Throwable { return new JpaTransactionManager( this.entityManager().getObject() ); } }
Теперь давайте добавим простой тестовый пример JUnit, чтобы проверить, действительно ли наше приложение Spring работает должным образом. Прежде чем сделать это, клиенты базы данных должны быть созданы:
> mysql -u root mysql> create database customers; Query OK, 1 row affected (0.00 sec)
А вот CustomerServiceTestCase, который пока имеет один тест для создания клиента и проверки его фактического создания.
package com.example.spring; import static org.hamcrest.CoreMatchers.notNullValue; import static org.junit.Assert.assertThat; import javax.inject.Inject; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.support.AnnotationConfigContextLoader; import com.example.spring.config.AppConfig; import com.example.spring.domain.Customer; import com.example.spring.services.CustomerService; @RunWith( SpringJUnit4ClassRunner.class ) @ContextConfiguration(loader = AnnotationConfigContextLoader.class, classes = { AppConfig.class } ) public class CustomerServiceTestCase { @Inject private CustomerService customerService; @After public void tearDown() { customerService.deleteAll(); } @Test public void testCreateCustomerAndVerifyItHasBeenCreated() throws Exception { Customer customer = customerService.create( "Customer A" ); assertThat( customerService.find( customer.getId() ), notNullValue() ); } }
Это выглядит просто и понятно. Теперь давайте подумаем о сценарии, когда создание клиента прошло успешно, но поиск не удался из-за тайм-аута запроса. Для этого нам нужна помощь Байтмана .
Короче говоря, Byteman — это структура манипулирования байт-кодом. Это реализация агента Java, которая работает с JVM (или присоединяется к нему) и изменяет байт-код запущенного приложения, изменяя его поведение. У Байтмена очень хорошая документация и собственный богатый набор определений правил, позволяющих выполнять практически все, что может придумать разработчик. Кроме того, он имеет довольно хорошую интеграцию с фреймворком JUnit . По этому вопросу тесты Byteman должны выполняться с @RunWith (BMUnitRunner.class) , но мы уже используем @RunWith (SpringJUnit4ClassRunner.class) и JUnit.не позволяет указывать несколько участников теста. Выглядит как проблема, если вы не знакомы с механикой JUnit @Rule . Оказывается, преобразование BMUnitRunner в правило JUnit является довольно простой задачей:
package com.example.spring; import org.jboss.byteman.contrib.bmunit.BMUnitRunner; import org.junit.rules.MethodRule; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; public class BytemanRule extends BMUnitRunner implements MethodRule { public static BytemanRule create( Class< ? > klass ) { try { return new BytemanRule( klass ); } catch( InitializationError ex ) { throw new RuntimeException( ex ); } } private BytemanRule( Class<!--?--> klass ) throws InitializationError { super( klass ); } @Override public Statement apply( final Statement statement, final FrameworkMethod method, final Object target ) { Statement result = addMethodMultiRuleLoader( statement, method ); if( result == statement ) { result = addMethodSingleRuleLoader( statement, method ); } return result; } }
А внедрение JUnit @Rule так просто:
@Rule public BytemanRule byteman = BytemanRule.create( CustomerServiceTestCase.class );
Легко, правда? Сценарий, о котором мы упоминали ранее, можно немного перефразировать: когда выполняется оператор JDBC для выбора из таблицы «customer», мы должны потерпеть неудачу с исключением по таймауту. Вот как выглядит тестовый пример JUnit с дополнительными аннотациями Byteman :
@Test( expected = DataAccessException.class ) @BMRule( name = "introduce timeout while accessing MySQL database", targetClass = "com.mysql.jdbc.PreparedStatement", targetMethod = "executeQuery", targetLocation = "AT ENTRY", condition = "$0.originalSql.startsWith( \"select\" ) && !flagged( \"timeout\" )", action = "flag( \"timeout\" ); throw new com.mysql.jdbc.exceptions.MySQLTimeoutException( \"Statement timed out (simulated)\" )" ) public void testCreateCustomerWhileDatabaseIsTimingOut() { Customer customer = customerService.create( "Customer A" ); customerService.find( customer.getId() ); }
Мы могли бы прочитать это так: «Когда кто — то звонит ExecuteQuery метод PreparedStatement класс и запрос начинается с„SELECT“ , чем MySQLTimeoutException будет отброшен, и это должно произойти только один раз (контролируется таймаут флаг)». При выполнении этого тестового примера печатается трассировка стека в консоли и ожидается, что будет сгенерировано исключение DataAccessException :
com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement timed out (simulated) at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:1.7.0_21] at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:57) ~[na:1.7.0_21] at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:1.7.0_21] at java.lang.reflect.Constructor.newInstance(Constructor.java:525) ~[na:1.7.0_21] at org.jboss.byteman.rule.expression.ThrowExpression.interpret(ThrowExpression.java:231) ~[na:na] at org.jboss.byteman.rule.Action.interpret(Action.java:144) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.fire(InterpretedHelper.java:169) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.execute0(InterpretedHelper.java:137) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.execute(InterpretedHelper.java:100) ~[na:na] at org.jboss.byteman.rule.Rule.execute(Rule.java:682) ~[na:na] at org.jboss.byteman.rule.Rule.execute(Rule.java:651) ~[na:na] at com.mysql.jdbc.PreparedStatement.executeQuery(PreparedStatement.java) ~[mysql-connector-java-5.1.24.jar:na] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.extract(ResultSetReturnImpl.java:56) ~[hibernate-core-4.2.0.Final.jar:4.2.0.Final] at org.hibernate.loader.Loader.getResultSet(Loader.java:2031) [hibernate-core-4.2.0.Final.jar:4.2.0.Final]
Выглядит хорошо, а как насчет другого сценария: создание клиента прошло успешно, но найти не удалось, потому что база данных вышла из строя? Это немного сложнее, но в любом случае это легко сделать, давайте посмотрим:
@Test( expected = CannotCreateTransactionException.class ) @BMRules( rules = { @BMRule( name="create countDown for AbstractPlainSocketImpl", targetClass = "java.net.AbstractPlainSocketImpl", targetMethod = "getOutputStream", condition = "$0.port==3306", action = "createCountDown( \"connection\", 1 )" ), @BMRule( name = "throw IOException when trying to execute 2nd query to MySQL", targetClass = "java.net.AbstractPlainSocketImpl", targetMethod = "getOutputStream", condition = "$0.port==3306 && countDown( \"connection\" )", action = "throw new java.io.IOException( \"Connection refused (simulated)\" )" ) } ) public void testCreateCustomerAndTryToFindItWhenDatabaseIsDown() { Customer customer = customerService.create( "Customer A" ); customerService.find( customer.getId() ); }
Позвольте мне объяснить, что здесь происходит. Мы хотели бы сидеть на уровне сокетов и фактически контролировать связь как можно ближе к сети, а не на уровне драйвера JDBC. Вот почему мы используем AbstractPlainSocketImpl . Мы также знаем, что порт MySQL по умолчанию — 3306, поэтому мы используем только сокеты, открытые на этом порту. Другой факт, мы знаем, что первый созданный сокет соответствует созданию клиента, и мы должны позволить этому пройти. Но второй соответствует найти и должен потерпеть неудачу. CreateCountDown назвал «соединение» служит этой цели: первый вызов проходит через (защелка не сосчитать до нуля еще) , но второй триггеры вызова MySQLTimeoutExceptionисключение. При выполнении этого тестового примера печатается трассировка стека в консоли и ожидается, что будет выдано исключение CannotCreateTransactionException :
Caused by: java.io.IOException: Connection refused (simulated) at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:1.7.0_21] at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:57) ~[na:1.7.0_21] at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:1.7.0_21] at java.lang.reflect.Constructor.newInstance(Constructor.java:525) ~[na:1.7.0_21] at org.jboss.byteman.rule.expression.ThrowExpression.interpret(ThrowExpression.java:231) ~[na:na] at org.jboss.byteman.rule.Action.interpret(Action.java:144) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.fire(InterpretedHelper.java:169) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.execute0(InterpretedHelper.java:137) ~[na:na] at org.jboss.byteman.rule.helper.InterpretedHelper.execute(InterpretedHelper.java:100) ~[na:na] at org.jboss.byteman.rule.Rule.execute(Rule.java:682) ~[na:na] at org.jboss.byteman.rule.Rule.execute(Rule.java:651) ~[na:na] at java.net.AbstractPlainSocketImpl.getOutputStream(AbstractPlainSocketImpl.java) ~[na:1.7.0_21] at java.net.PlainSocketImpl.getOutputStream(PlainSocketImpl.java:214) ~[na:1.7.0_21] at java.net.Socket$3.run(Socket.java:915) ~[na:1.7.0_21] at java.net.Socket$3.run(Socket.java:913) ~[na:1.7.0_21] at java.security.AccessController.doPrivileged(Native Method) ~[na:1.7.0_21] at java.net.Socket.getOutputStream(Socket.java:912) ~[na:1.7.0_21] at com.mysql.jdbc.MysqlIO.(MysqlIO.java:330) ~[mysql-connector-java-5.1.24.jar:na]
Большой! Возможности, которые Byteman предоставляет для различных симуляций ошибок, огромны. Тщательное добавление тестовых наборов для проверки того, как приложение реагирует на ошибочные условия, значительно повышает надежность приложения и устойчивость к сбоям. Большое спасибо ребятам из Byteman !
Пожалуйста, найдите полный проект на GitHub .