Статьи

Внедрение ошибок с Byteman и JUnit: сделайте еще больше, чтобы обеспечить надежность ваших приложений

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