В отличие от множества платформ Java страдает от недостатка библиотек заглушек соединений. Если вы давно в этом мире, то наверняка должны быть знакомы с WireMock, Betamax или даже Spock. Многие разработчики в тестах используют Mockito для описания поведения объектов, DataJpaTest с локальной h2 базой данных, Cucumber тесты. Сегодня вы познакомитесь с легковесной альтернативной, которая поможет справиться с разными проблемами, с которыми вы могли сталкиваться используя эти подходы. В частности, anyStub пытается решить следующие проблемы:


  • упростить кофигурацию тестового окружения
  • автоматизировать сбор данных для тестов
  • остаться в тестировании вашего проложения и избежать тестирования чего-то ещё

Что такое anyStub и как это работает


AnyStub оборачивает вызовы функций, пытается найти совпадающие вызовы, которые были уже записанны. Две вещи могут произойти при этом:


  • если существует совпадающий вызов, anyStub восстановит записанный результат, ассоциированный с этим вызовом и вернет его
  • если нет совпадающего вызова и обращение во внешнюю систему разрешено, anyStub выполнит этот вызов, запишет этот результат и вернет его

Из коробки anyStub предоставляет обертки для http-клиента из Apache HttpClient для создания заглушек http запросов и несколько интерфейсов из javax.sql.* для DB-соединений. Также вам предоставляется API для создания заглушек других соединений.


AnyStub это простая библиотека классов и не требует специальной настройки вашего окружения. Эта библиотека нацелена на работу со spring-boot приложениями и вы получите максимум выгоды следуя этому пути. Вы можете исползовать её вне Spring, в plain Java приложениях, но определённо вам придется выполнить дополнительную работу. Дальнейшее описание ориентировано на тестирование spring-boot приложений.


Давайте посмотрим на интеграционное тестирование. Это наиболее захватывающий и всесторонний способ протестировать вашу систему. На самом деле, spring-boot и JUnit делают почти всё за вас когда вы напишите магические аннотации:


@RunWith(SpringRunner.class)
@SpringBootTest

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


С anyStub вам не надо калечить spring-contex. Вместо этого, сохранение контекста близким к production конфигурации простое и прямолинейное.


В этом примере мы посмотрим, как подключить anyStub к Consuming a RESTful Web Service из руководства от Pivotal.


Подключение библиотеки через pom.xml


    <dependency>
        <groupId>org.anystub</groupId>
        <artifactId>anystub</artifactId>
        <version>0.2.27</version>
        <scope>test</scope>
    </dependency>

Следующий шаг — это модификация spring контекста.


package hello;

import org.anystub.http.StubHttpClient;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.client.RestTemplateCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

@Configuration
public class TestConfiguration {

    @Bean
    public RestTemplateBuilder builder() {

        RestTemplateCustomizer restTemplateCustomizer = new RestTemplateCustomizer() {
            @Override
            public void customize(RestTemplate restTemplate) {
                HttpClient real = HttpClientBuilder.create().build();
                StubHttpClient stubHttpClient = new StubHttpClient(real);
                HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
                requestFactory.setHttpClient(stubHttpClient);
                restTemplate.setRequestFactory(requestFactory);
            }
        };

        return new RestTemplateBuilder(restTemplateCustomizer);
    }
}

Эта модификация не меняет взаимосвязей компонентов в приложении, а только заменяет реализацию одного интерфейса. Это отправляет нас к Принципу подстановки Барбары Лисков. Если дизайн вашего приложения его не нарушает, то и эта подстановка не нарушит функциональность.


Всё готово. Этот проект уже включает тест.


@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTest {

    @Autowired
    private RestTemplate restTemplate;

    @Test
    public void contextLoads() {
        assertThat(restTemplate).isNotNull();
    }

}

Этот тест пустой, но он уже запускает контекст приложения. Самое интересное начинается здесь. Как мы говорили выше, контекст приложения в тесте совпадает с рабочим контекстом, в котором создается CommandLineRunner, в котором выполняется http-запрос к внешней системе.


@SpringBootApplication
public class Application {

    private static final Logger log = LoggerFactory.getLogger(Application.class);

    public static void main(String args[]) {
        SpringApplication.run(Application.class);
    }

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }

    @Bean
    public CommandLineRunner run(RestTemplate restTemplate) throws Exception {
        return args -> {
            Quote quote = restTemplate.getForObject(
                    "https://gturnquist-quoters.cfapps.io/api/random", Quote.class);
            log.info(quote.toString());
        };
    }
}

Это достаточно для демонстрации работы библиотеки. После первого запуска тестов вы найдете новый файл complete/src/test/resources/anystub/stub.yml.


request0:
  exception: []
  keys: [GET, HTTP/1.1, 'https://gturnquist-quoters.cfapps.io/api/random']
  values: [HTTP/1.1, '200', OK, 'Content-Type: application/json;charset=UTF-8', 'Date:
      Thu, 25 Apr 2019 23:04:49 GMT', 'X-Vcap-Request-Id: 5ffce9f3-d972-4e95-6b5c-f88f9b0ae29b',
    'Content-Length: 177', 'Connection: keep-alive', '{"type":"success","value":{"id":3,"quote":"Spring
      has come quite a ways in addressing developer enjoyment and ease of use since
      the last time I built an application using it."}}']

Что произошло? spring-boot встроил RestTemplateBuilder из тестовой конфигурации в приложение. Это привело к работе приложения через stub-реализацию http-клиента. StubHttpClient перехватил запрос, не нашел файл заглушки, выполнил запрос, сохранил результат в файле и вернул восстановленный из файла результат.


С этого момента, вы можете запускать этот тест без интернет подключения и этот запрос будет выполнятся успешно. restTemplate.getForObject() будет возвращать одинаковый результат. Вы можете полагаться на этот факт в ваших будущих тестах.


Все описанные изменения вы можете найти на GitHub.


На самом деле мы все еще не создали ни одного теста. Перед написанием тестов давайте посмотрим как это работает с базами данных.


В этом примере мы добавим интеграционной тест к Accessing Relational Data using JDBC with Spring из руководства от Pivotal.


Тестовая конфигурация для этого случая выглядит так:


package hello;

import org.anystub.jdbc.StubDataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class TestConfiguration {

    @Bean
    public DataSource dataSource() {

        JdbcDataSource ds = new JdbcDataSource();
        ds.setURL("jdbc:h2:./test");
        return new StubDataSource(ds);
    }

}

Здесь создается обычный datasource к внешней базе данных и оборачивается stub-реализацией — класс StubDataSource. Spring-boot встраивает его в контекст. Ещё нам необходимо создать хотя бы один тест для запуска spring-контекста в тесте.


package hello;

import org.anystub.AnyStubId;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import static org.junit.Assert.*;

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTest {

    @Test
    @AnyStubId
    public void test() {

    }

}

Это опять пустой тест — его единственная задача запустить контекст приложения. Здесь мы видим очень важную аннотацию @AnystubId, но она пока не будет задействована.


После первого запуска вы найдете новый файл src/test/resources/anystub/stub.yml, который включает все обращения к базе данных. Вы будете удивлены как spring работает за сценой с базами данных. Отметим что, новые запуски теста не приведут к реальному обращению к базе данных. Если вы удалите test.mv.db он не появится после повторных запусков тестов. Полный набор изменений можно посмотреть на GitHub.


Подведем итоги. с anyStub:


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

Вероятно, у вас есть вопросы: как это покрывает случаи, когда база данных еще не существует, что делать с негативным тестированием и обработкой исключений. Мы вернемся к этому, но сначала разберемся с написанием простых тестов.


Сейчас мы по-экспериментируем над Consuming a RESTful Web Service. Этот проект не содержит компонент, которые могут быть протестированы. Ниже создаются два класса, которые должны изобразить два слоя какого-то дизайна архитектуры.


package hello;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
public class DataProvider {
    private final RestTemplate restTemplate;

    public DataProvider(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    Quote provideData() {
        return restTemplate.getForObject(
                "https://gturnquist-quoters.cfapps.io/api/random", Quote.class);
    }
}

DataProvider обеспечивает доступ к данным в изменчивой внешней системе.


package hello;

import org.springframework.stereotype.Component;

@Component
public class DataProcessor {

    private final DataProvider dataProvider;

    public DataProcessor(DataProvider dataProvider) {
        this.dataProvider = dataProvider;
    }

    int processData() {
        return dataProvider.provideData().getValue().getQuote().length();
    }
}

DataProcessor выполнят обработку данных из внешней системы.


Мы намерены протестировать DataProcessor. Необходимо протестировать корректность алгоритма обработки и защитить систему от деградации от изменений в будущем.


Чтобы достичь этих целей вы можете подумать о создании мок-объекта DataProvider с набором данных и его передачи в конструктор DataProcessor в тестах. Другой способ может состоять в декомпозиции DataProcessor, чтобы выделить алгоритм обработки класса Quote. Затем, такой класс легко протестировать с помощью юнит-тестов (Наверняка, это рекомендуемый способ в уважаемых книжках про чистый код). Попробуем избежать изменений кода и изобретения тестовых данных и просто напишем тест.


@RunWith(SpringRunner.class)
@SpringBootTest
public class DataProcessorTest {

    @Autowired
    private DataProcessor dataProcessor;

    @Test
    @AnyStubId(filename = "stub")
    public void processDataTest() {
        assertEquals(131, dataProcessor.processData());
    }

}

Время рассказать об аннотации @AnystubId. Эта аннотация помогает управлять и контролировать stub-файлы в тестах. Её можно использовать с тест-классом или его методом. Эта аннотация устанавливает индивидуальный stub-файл для соответствующей области. Если какая-то область одновременно покрыта аннотациями уровня класса и метода — аннотация метода имеет приоритет. Эта аннотация имеет пераметр filename, который определяет имя stub-файла. расширение ".yml" добавляется автоматически, если пропущено. Запустив этот тест вы не обнаружите новый файл. Файл src/test/resources/anystub/stub.yml уже был создан ранее и этот тест его переиспользует. Число 131 мы получили из этой заглушки, проанализировав результат запроса.


 @Test
    @AnyStubId
    public void processDataTest2() {
        assertEquals(131, dataProcessor.processData());

        Base base = getStub();

        assertEquals(1, base.times("GET"));
        assertTrue(base.history().findFirst().get().matchEx_to(null, null, ".*gturnquist-quoters.cfapps.io.*"));
    }

В этом тесте аннотация @AnyStubId появляется без параметра filename. В этом случае используется файл src/test/resources/anystubprocessDataTest2.yml. Имя файла строится из имени функции (класса) + ".yml". Раз anyStub создает новый файл для этого теста необходимо выполнить реальный вызов системы. И это наша удача, что новая цитата имеет такую-же длину. Две последних проверки показывают как проверить поведение приложения. Вам доступно: выбор запросов по параметрам или части параметров и подсчет количества запросов. Есть несколько вариантов функций times and match, с которыми можно ознакомиться в документации.


  @Test
    @AnyStubId(requestMode = RequestMode.rmTrack)
    public void processDataTest3() {
        assertEquals(79, dataProcessor.processData());
        assertEquals(79, dataProcessor.processData());
        assertEquals(168, dataProcessor.processData());
        assertEquals(79, dataProcessor.processData());

        Base base = getStub();

        assertEquals(4, base.times("GET"));

    }

В этом тесте @AnyStubId появляется с новым параметром requestMode. Он позволяет управлять разрешениями дла stub-файлов. Существует два аспекта для управления: поиск по файлу и разрешение на вызов внешней системы.


RequestMode.rmTrack устанавливает следующие правила: если файл только что создан все запросы отправляются во внешнюю систему и записываются в файл вместе с ответами не зависимо от того есть ли в файле идентичный запрос (дубликаты в файле допускаются). Если перед запуском тестов stub-файл существует запросы во внешнюю систему запрещены. Вызовы ожидаются точно в той же последовательности. Если очередной запрос не совпадает с запросом в файле, генерируется исключение.


RequestMode.rmNew этот режим активируется по-умолчанию. Каждый запрос ищется в stub-файле. Если совпадающий запрос найден — соответствующий результат восстанавливается из файла, запрос во внешнюю систему откладывается. Если запрос не найден — осуществляется запрос внешней системы, результат сохраняется в файле. Дубликаты запросов в файле — не возникают.


RequestMode.rmNone Каждый запрос ищется в stub-файле. Если совпадающий запрос найден — его результат восстанавливается из файла. Если тест формирует запрос, отсутствующий в файле — то генерируется исключение.


RequestMode.rmAll перед первым запросом stub-файл отчищается. Все запросы записываются в файл (дубликаты в файле допускаются). Вы можете использовать этот режим, если хотите понаблюдать за работой соединения.


RequestMode.rmPassThrough все запросы напрямую отправляются во внешнюю систему обходя stub реализации.


Эти изменения доступны на GitHub


Что ещё?


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


Часто исключения генерируются классами верхнего уровня, при этом классы соединений получают допустимый ответ (Вероятно, с кодом ошибки). В этом случае anyStub ответственный за воспроизведение того самого ответа с кодом ошибки, а классы верхних уровней будут так же генерировать исключения для ваших тестов.


Добавляйте файлы заглушек в репоситорий.


Не бойтесь удалять и перезаписывать файлы заглушек.


Управляйте файлами заглушек разумно. Вы можете переиспользовать один файл в нескольких тестах или обеспечить индивидуальный файл для каждого теста. Пользуйтесь этой возможностью для ваших потребностей. Но обычно использование одного файла с разными режимами доступа — плохая идея.


Это все главные возможности anyStub.

Комментарии (12)


  1. G1yyK
    08.05.2019 14:23

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

    А чем это лучше MokMvc?


    1. feech1 Автор
      08.05.2019 14:44

      Все правильно. Anystub только проксирует и сохраняет за разработчика. Но работает на уровне вызова функций. В результате появляется возможность подменять любой объект.
      Непонятен вопрос об управлении файлами — их можно добавить в репозиторий


      1. nrcmn
        08.05.2019 20:04

        Насколько я понял предыдущий вопрос, ответы сохраняются в файлы, если у вас есть 10 серверов, которые обрабатывают запросы, при попадании запроса, он все равно запрашивает ответ от внешней системы и сохраняет его в файл (или heap), при этом все копится. Если, как я написал, серверов у вас много, а слои фронта (api) и backend(app) разделены, у вас вся информация будет только накапливаться. Вопрос, что с ней делать? Особенно если у вас монолит и 200 методов api.


        1. ggo
          08.05.2019 21:51

          Копится будет если режим записи включен все время.
          Обычно же запись включена только при создании кейса.
          Далее, при тестировании происходит только «воспроизведение» записи, которая предоставляет ответ для тестируемого кейса.


          1. nrcmn
            08.05.2019 22:10

            Так а что делать если серверов несколько и на каждом разные ответы?


            1. ggo
              08.05.2019 22:18

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


              1. nrcmn
                08.05.2019 22:32

                Есть кластер, в нем 10 апп серверов, запросы на них идут с api-серверов, пришел первый запрос, попал на app-1, получил ответ от внешней системы 404, вернул его api, пришел такой же запрос, попал на app-5, который от внешней системы получил 200 и тело ответа, все это закэшировалось, теперь все такие запросы будут получать либо 200, либо 404, в зависимости от того, на какие апп-серверы попадут. Что с этим делать?


                1. feech1 Автор
                  09.05.2019 01:59

                  В статье рассказывается о возможности работы с http и sql источниками данных. В вашем случае в тесте вы проверяете как работает ваш api-сервер с кластером апп-серверов.
                  Если в вашем апп-кластере нет loadbalancer и api-сервер сам балансирует запросы, то каждый запрос имеет уникальное поле host, значит запросы к app-1 и app-5 будут различны и для каждого будет своя запись со своим ответом
                  Если у кластера есть балансер, значит вероятно api-сервер будет формировать одинаковый запрос несколько раз ожидая другой ответ. для этого предназначен режим rmTrack


        1. feech1 Автор
          09.05.2019 01:46

          Мы говорим о конкретном тесте кейсе? Тогда было бы правильным описать сценарий теста.
          Независимо от количества серверов, перед вызовом внешней системы, может осуществлятся поиск запроса в файле, который соответствует текущему тесту.
          Обычно сценарии тестов конечны — значит копить необходимо конечное количество запросов.
          С созданными файлами надо обходится так же как со всеми стабами — хранить пока они представляют ценность.
          Согласен, что одним тестом покрыть api из 200 методов непросто. В статье говорится о создании одного контекста приложения для всех тестов. Наверное будет разумно одним тестом покрывать один use-case пользователя, возможно, он будет задействовать несколько api вызовов.


          1. nrcmn
            09.05.2019 02:02

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


            1. feech1 Автор
              09.05.2019 02:19

              Нагрузочное тестирование заглушек. Извините, не хватает кармы поставить вам +1


              1. nrcmn
                09.05.2019 02:20

                :) энтерпрайз он такой