Статьи

Инъекция неисправностей с помощью Byteman и JUnit

Время, когда наши приложения жили изолированно, давно прошло. В настоящее время приложения — это очень сложные звери, которые разговаривают друг с другом, используя множество API и протоколов, хранят данные в традиционных базах данных или базах данных NoSQL, отправляют сообщения и события по проводам… Как часто вы задумывались о том, что произойдет, если, например, база данных выходит из строя, когда ваше приложение активно его запрашивает? Или какая-то конечная точка API вдруг начинает отказываться от соединения? Разве не было бы неплохо, чтобы такие несчастные случаи освещались как часть вашего набора тестов? Вот что такое инъекция ошибок и структура Byteman . В качестве примера мы создадим реалистичное полнофункциональное приложение Spring, которое использует Hibernate / JPA для доступа к базе данных MySQL и управления клиентами. В рамках набора тестов интеграции JUnit приложения мы будем включать три вида тестовых случаев:

  • магазин / найти покупателя
  • сохранить клиента и попытаться запросить базу данных, когда она не работает (имитация сбоя)
  • тайм-аут запроса клиента и базы данных (симуляция ошибок)

Существует только два предварительных условия для запуска приложения на вашем локальном компьютере разработки:

  • MySQL сервер установлен и имеет базу клиентов
  • Oracle JDK установлен и переменная окружения JAVA_HOME указывает на него

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

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 :

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
26
27
28
29
30
31
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 ). Все остальное должно быть очень знакомым.

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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 работает должным образом. Прежде чем сделать это, клиенты базы данных должны быть созданы:

1
2
3
> mysql -u root
mysql> create database customers;
Query OK, 1 row affected (0.00 sec)

А вот CustomerServiceTestCase, который пока имеет один тест для создания клиента и проверки его фактического создания.

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
26
27
28
29
30
31
32
33
34
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 является довольно простой задачей:

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
26
27
28
29
30
31
32
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 так просто:

1
@Rule public BytemanRule byteman = BytemanRule.create( CustomerServiceTestCase.class );

Легко, правда? Сценарий, о котором мы упоминали ранее, можно немного перефразировать: когда выполняется оператор JDBC для выбора из таблицы «customer», мы должны потерпеть неудачу с исключением по таймауту. Вот как выглядит тестовый пример JUnit с дополнительными аннотациями Byteman :

01
02
03
04
05
06
07
08
09
10
11
12
13
@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 :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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]

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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 с именем «connection» служит этим целям: первый вызов проходит (защелка еще не считается до нуля), но второй вызов вызывает исключение MySQLTimeoutException . При выполнении этого тестового примера печатается трассировка стека в консоли и ожидается, что будет выдано исключение CannotCreateTransactionException :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
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 .