Время, когда наши приложения жили изолированно, давно прошло. В настоящее время приложения — это очень сложные звери, которые разговаривают друг с другом, используя множество 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 .