Как стать автором
Обновить

Сквозное и интеграционное тестирование просто, как юнит-тесты

Время на прочтение17 мин
Количество просмотров9.2K

Когда изменения затрагивают несколько микросервисов, возникает вопрос, как протестировать их в связке. Можно покрыть границы сервисов юнит тестами, а интеграцию проверить, развернув измененный код на тестовом окружении. У такого подхода две главные проблемы: цикл изменения-тестирование-исправления становится достаточно долгим и нужно много полноценных окружений, чтобы обеспечить параллельную работу нескольких разработчиков. Давайте попробуем решить проблему иначе.

Будем решать проблему последовательно, по шагам преодолевая возникающие трудности.
Код можно найти на GitHub. К каждой части будет ссылка на ревизию.

1. Постановка задачи

rev:2b8fcd50

Для примера возьмем два микросервиса, написанных с использованием spring-boot. Для простоты у нас будет многомодульный мавен-проект с двумя севисами: client-service и worker-service. Допустим, что надо реализовать функционал:

client-service должен принимать http запросы с задачами и отправлять их на выполнение в worker-service, а worker-service возвращает идентификатор выполняемой задачи.

Получилось два эндпоинта:

ClientServiceEndpoint

    @PostMapping("/task")
    public String placeTask(@RequestBody ClientRequest request){
        return restTemplate.postForObject(config.getWorkerUrl(),request,WorkerResponseDto.class).getJobId();
    }

WorkerServiceEndpoint

    @PostMapping("/task")
    public WorkerResponseDto placeTask(@RequestBody ClientRequest request){
        WorkerResponseDto workerResponseDto=new WorkerResponseDto();
        workerResponseDto.setJobId(UUID.randomUUID().toString());
        return workerResponseDto;
    }

Оба проекта можно запустить локально (ClientServiceApplication.main и WorkerServiceApplication.main). Теперь можно к ним написать мануальные тесты (кодом или в какой-нибудь специализированной среде вроде Talend Api Tester).

Этот подход работает. Его даже можно автоматизировать, если отдельными шагами запускать приложения. Но при автоматизации можно столкнуться со следующими трудностями:

  1. Сложно следить за запущенными приложениями (надо не забыть их остановить после тестов)

  2. Если микросервисов много, то их придется все запускать руками при разработке (или прогонять всю сборку целиком мавеном/гредлом)

  3. Если у приложений есть состояние, то тесты могут начать влиять друг на друга.

  4. Сложно тестировать сценарии деградации при недоступности одного из микросервисов. Перекликается с пунктом 1 и 3: если остановить один из компонентов, могут упасть тесты, использующие этот компонент. Надо после каждого теста восстановить исходное состояние (и не запускать тесты одновременно).

Можно сформулировать требования к идеальным межкомпонентным тестам:

  1. Должны запускаться одной кнопкой run test из IDE

  2. Написание не должно представлять трудностей и не должно сильно отличаться от написания юнит-тестов.

  3. Должны поддерживать отладку отдельных микросервисов

  4. Должны быть изолированы друг от друга

  5. Запуск теста должен быть достаточно быстрый, чтобы при разработке можно было пользоваться практикой TDD

  6. Должны быть интегрированы c CI. Идеально, чтобы их можно было прогонять при проверке пул реквестов.

Давайте попробуем решить задачу тестирования с учетом этих требований.

2. Пробуем наивное решение: сделаем модуль, зависимый от модулей микросервисов и напишем тест в нем.

rev:cfbebf68

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

public class TaskIntegrationTest {
    @Test
    public void testTaskSubmission() throws Exception {
        ClientServiceApplication.main(new String[0]);
        WorkerServiceApplication.main(new String[0]);

        HttpResponse<String> response = HttpClient.newBuilder().build().send(
                HttpRequest.newBuilder()
                        .method("POST", HttpRequest.BodyPublishers.ofString("{ \"data\":\"my-data\"}"))
                        .header("Content-Type", "application/json")
                        .uri(URI.create("http://localhost:8080/task"))
                        .build(),
                HttpResponse.BodyHandlers.ofString()
        );

        assertEquals(response.statusCode(), 200);
        assertFalse(response.body().isBlank());
    }
}

Но такой тест не заработает. Причина: у нашего теста в класспассе оказалось два application.yml и Spring берет первый попавшийся. Исправим это, задав имена приложений. Например, для client-service назовем файл конфигурации application-client.yml и зададим имя так:

    public static void main(String[]args){
        SpringApplication.run(ClientServiceApplication.class,"--spring.config.name=application-client");
    }

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

3. Добавление зависимостей в один из сервисов затрагивает другие сервисы в тестах

rev:7c8abae7

Если у разных сервисов разный набор зависимостей, то тесты могут вести себя непредсказуемым образом. Например, если мы хотим защитить client-service и добавляем spring-boot-starter-security в зависимости, то неожиданно оказывается защищенным и worker-service. И тесты падают несмотря на то, что production build у worker-service не поменялся. Можно предположить и существование обратного случая: тесты проходят, а на реальном окружении что-то не работает.

Вывод: чтобы тестировать микросервисы надо запускать каждый из них с тем же класспасом, что будет использован в боевом окружении.

4. Используем maven-dependency-plugin, чтобы получить правильный класспасс

rev:50d2802f

В этой части речь пойдет про maven. Для gradle можно сделать примерно так же.

Чтобы получить правильный список зависимостей в правильном порядке можно вызвать mvn compile dependency:build-classpath. Здесь включение фазы compile обязательно, потому что иначе мавен будет
считать внутрипроектные зависимости внешними и пытаться найти их в .m2 и внешних репозиториях. Подробности тут: MNG-3283.

Далее вопрос в том, кто будет вызывать dependency-plugin? Есть следующий варианты:

  1. Прописываем в pom.xml, локально вызываем из командной строки, на CI вызовется автоматически.

  2. Используем maven-embedder и вызываем прямо из теста. Проблема в том, что у maven-embedder нет собранной версии с зависимостями, а тянет за собой он очень много. И это с легкостью ломает тесты. Но можно его собрать и положить в свой репозиторий.

Мне кажется, что достаточно первого варианта. Дописываем в pom.xml


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>3.3.0</version>
    <executions>
        <execution>
            <id>generate classpath file for IT</id>
            <goals>
                <goal>build-classpath</goal>
            </goals>
            <phase>process-classes</phase>
            <configuration>
                <includeScope>runtime</includeScope>
                <outputFile>${project.build.directory}/classpath_${project.artifactId}.txt</outputFile>
            </configuration>
        </execution>
    </executions>
</plugin>

Теперь после исполнения mvn process-classes в target окажутся файлы со списком зависимостей. Не составит труда их найти и прочитать, если знать, где находится корень проекта. Проблема тут в том, что текущая директория при запуске из IDE и при запуске maven-surefire-plugin может отличаться. Но в любом случае она находится внутри проекта. Поэтому можно положить файл-маркер рядом с самым верхним pom.xml, искать его вверх, а потом от него рекурсивно спускаться.

    private static File findTopProjectDir() throws IOException {
        File topProjectDir = new File(".").getCanonicalFile();
        do {
            if (new File(topProjectDir, ".top.project.dir").exists()) {
                return topProjectDir;
            }
            topProjectDir = topProjectDir.getParentFile();
        } while (topProjectDir != null);

        throw new IllegalStateException("Cannot find marker file .top.project.dir starting from " + new File(".").getAbsolutePath());
    }

И вычитать класспассы, складывая их в Map по ключу artifact_id (если у вас artifact_id не уникальный, можно использовать group_id:artifact_id). Особенность тут заключается в том, что build_classpath не включает target/classes того модуля, для которого класспасс строится, эту директорию надо добавить дополнительно в начало classpath:

    private static void searchForClassPathFiles(File topProjectDir, Map<String, List<String>> results) throws IOException {
        File pomXml = new File(topProjectDir, "pom.xml");
        if (pomXml.exists()) {
            File targetDir = new File(topProjectDir, "target");
            File[] classPathFiles = targetDir.listFiles(pathname -> pathname.getName().startsWith("classpath_") && pathname.getName().endsWith(".txt"));
            if (classPathFiles != null) {
                if (classPathFiles.length > 1) {
                    throw new IllegalStateException("Found more than one classpath file in dir " + targetDir.getAbsolutePath());
                }
                if (classPathFiles.length == 1) {
                    File classPathFile = classPathFiles[0];
                    List<String> classPath = new ArrayList<>(Arrays.asList(Files.readString(classPathFile.toPath()).split(System.getProperty("path.separator"))));
                    // maven-dependency-plugin build-classpath does not include module classes, let's include them now
                    classPath.add(0, new File(targetDir, "classes").getAbsolutePath());

                    String artifactId = classPathFile.getName().replaceAll("^classpath_", "").replaceAll(".txt$", "");
                    if (results.containsKey(artifactId)) {
                        throw new IllegalStateException("Duplicate artifact id: " + artifactId);
                    }
                    results.put(artifactId, classPath);
                }
            }
            File[] probablySubmodules = topProjectDir.listFiles(File::isDirectory);
            if (probablySubmodules != null) {
                for (File probablySubmodule : probablySubmodules) {
                    searchForClassPathFiles(probablySubmodule, results);
                }
            }
        }
    }

На CI все пройдет хорошо - файлы со списком зависимостей будут актуальными. А вот в локальной разработке сложно не забыть обновить файлы после изменения зависимостей в pom.xml. Чтобы программно заметить изменения, я предлагаю все pom.xml при сборке скопировать в target. Именно все, потому что прослеживать внутремодульные зависимости сложно:


<plugin>
    <groupId>com.coderplus.maven.plugins</groupId>
    <artifactId>copy-rename-maven-plugin</artifactId>
    <version>1.0</version>
    <executions>
        <execution>
            <id>copy-pom</id>
            <phase>process-classes</phase>
            <goals>
                <goal>copy</goal>
            </goals>
            <configuration>
                <sourceFile>pom.xml</sourceFile>
                <destinationFile>target/pom-copy.xml</destinationFile>
            </configuration>
        </execution>
    </executions>
</plugin>

И сравнить их с оригиналами перед вычитыванием classpath files.

    private static void checkPomChanges(File topProjectDir) throws IOException {
        File pomXml = new File(topProjectDir, "pom.xml");
        if (pomXml.exists()) {
            File targetPomFile = new File(new File(topProjectDir, "target"), "pom-copy.xml");
            if (!targetPomFile.exists()) {
                throw new IllegalStateException(targetPomFile.getAbsolutePath() + " is not generated, run `mvn process-classes` first");
            }
            if (!Files.readString(pomXml.toPath()).equals(Files.readString(targetPomFile.toPath()))) {
                throw new IllegalStateException(targetPomFile.getAbsolutePath() + " is not equal to " + pomXml.getAbsolutePath() + ", run `mvn process-classes` first");
            }
            File[] probablySubmodules = topProjectDir.listFiles(File::isDirectory);
            if (probablySubmodules != null) {
                for (File probablySubmodule : probablySubmodules) {
                    checkPomChanges(probablySubmodule);
                }
            }
        }
    }

5. Запускаем сервисы в отдельных процессах используя библиотеку nanocloud

rev:f9118159

Теперь, зная classpath, можно запустить сервисы в отдельных процессах. Запуск в одном процессе, но разных класслоадерах скорее всего приведет к трудностям, так как разные библиотеки используют разных общий стейт: системные переменные, Service Providers и другие возможности, которые приходят с boot class loader.

Запуск в отдельной jvm можно сделать с помощью ProcessBuilder. А можно воспользоваться
библиотекой nanocloud. Вот так можно запустить сервис:

        Cloud cloud = CloudFactory.createCloud(); 
        ViNode clientNode = cloud.node("client"); // каждая нода - от
        clientNode.x(VX.CLASSPATH).inheritClasspath(false);
        ViProps.at(clientNode).setLocalType(); // будем запускать локально, в отдельной jvm
        ClassPathHelper.getClasspathForArtifact("client-service")
                .forEach(classPathElement -> clientNode.x(VX.CLASSPATH).add(classPathElement));
        clientNode.exec(new Runnable() {
            @Override
            public void run() {
                ClientServiceApplication.main(new String[0]);
            }
        });

6. Добавляем обертки для ViNode, заменяем анонимные классы на лямбды

rev:f7e1724b

При запуске сервиса был использован анонимный класс, а не лямбда. Это было сделано потому, что nanocloud для пересылки объектов использует java-serialization с дополнением для сериализации анонимных классов. Это было удобно в java 1.6, но сейчас выглядит архаично. Но если просто заменить анонимный класс на лямбду, то произойдет ошибка сериализации. Поэтому удобно написать обертку (заодно научив ее различать callable и runnable):

public class Node implements ViConfigurable {

    private final ViNode node;

    public Node(Cloud cloud, String name) {
        node = cloud.node(name);
    }

    public void exec(SerializableRunnable runnable) {
        node.exec(runnable);
    }

    public <T> T execAndReturn(SerializableCallable<T> callable) {
        return node.exec(callable);
    }
    
    ...
    и другие методы, делегирующие к ViNode

    public interface SerializableRunnable extends Runnable, Serializable {
        void run();
    }

    public interface SerializableCallable<T> extends Callable<T>, Serializable {
        T call();
    }
}    

7. Выделяем свободные порты сервисам.

rev:90080a1c

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

Самый простой способ получить свободный порт такой:

    int freePort()throws Exception{
        try(ServerSocket socket=new ServerSocket(0)){
            return socket.getLocalPort();
        }
    }

Недостатки:

  1. Один и тот же порт может быть выдан несколько раз (пока приложение не запустится и не заберет порт себе)

  2. Порты будут разные от запуска к запуску, что усложняет отладку (например, при каждом перезапуске теста придется вводить новый адрес, если мы что-то тестируем в ручном режиме)

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

public class PortAllocator {
    private static final int CHUNK_SIZE = 10_000;
    // сохраняем ServerSocket в поле, чтобы он не был прибран ГЦ и 
    // другой запуск тестов не мог забрать базовый порт
    private static ServerSocket basePortHolder;
    private static int port;

    public static synchronized int freePort() {
        if (basePortHolder == null) {
            for (int i = 1; i < 6; i++) {
                try {
                    basePortHolder = new ServerSocket(i * CHUNK_SIZE);
                    break;
                } catch (IOException e) {
                    // ignore
                }
            }
            if (basePortHolder == null) {
                throw new IllegalStateException("Cannot find port base, all ports are occupied");
            }
            port = basePortHolder.getLocalPort();
        }
        // ищем следующий свободный порт
        while (port < basePortHolder.getLocalPort() + CHUNK_SIZE) {
            port++;
            if (portIsFree(port)) {
                return port;
            }
        }
        throw new IllegalStateException("Cannot find free port starting from " + basePortHolder.getLocalPort());
    }

    private static boolean portIsFree(int port) {
        try {
            // next line better than just new ServerSocket(port), 
            // check https://github.com/spring-projects/spring-framework/issues/17906 for discussion
            try (ServerSocket ignored = new ServerSocket(port, 0, InetAddress.getByName("localhost"))) {
                return true;
            }
        } catch (Exception e) {
            return false;
        }
    }
}

Теперь нужно раздать порты сервисам, а client-service еще должен узнать порт worker-service. Можно выделить абстрактную обертку над сервисом.

public interface Component {
    /**
     * Этот метод будет запущен для старта компонента
     * @param env список всех компонентов в текущем тесте
     */
    void start(Cloud cloud, List<Component> env);
}

Тогда в тесте можно будет писать вот так:

        Cloud cloud=CloudFactory.createCloud();
        ClientComponent clientComponent=new ClientComponent();
        // стартуем компоненты
        env(
            cloud,
            clientComponent,
            new WorkerComponent()
        );

И метод env будет таким:

    public static void env(Cloud cloud,Component...components){
        for(Component component:components){
            component.start(cloud,Arrays.asList(components));
        }
    }

Тогда компоненты смогут сами найти порты тех сервисов, которые им нужны. В нашем случае обертка для client-service будет выглядеть так:

public class ClientComponent implements Component {
    public static class Config {
        public final int restPort = PortAllocator.freePort();
    }

    public Config config = new Config();

    @Override
    public void start(Cloud cloud, List<Component> env) {
        Node clientNode = new Node(cloud, "client");
        clientNode.x(VX.CLASSPATH).inheritClasspath(false);
        ViProps.at(clientNode).setLocalType();
        ClassPathHelper.getClasspathForArtifact("client-service")
                .forEach(classPathElement -> clientNode.x(VX.CLASSPATH).add(classPathElement));

        // здесь мы используем порт
        clientNode.x(VX.JVM).setEnv("server.port", config.restPort + "");

        // здесь мы ищем WorkerService и передаем его порт в переменные окружения
        WorkerComponent worker = findComponent(env, WorkerComponent.class);
        clientNode.x(VX.JVM).setEnv("client-service.worker-url", "http://localhost:" + worker.config.restPort);

        clientNode.exec(() -> ClientServiceApplication.main(new String[0]));
    }
}

8. Распечатываем конфиги

rev:a020e1f9

У нас появился произвол в выборе портов, поэтому полезно их сразу распечатывать в консоль. Для http портов лучше распечатывать сразу вместе со ссылкой для быстрого запуска в браузере. Для этого параметризуем компоненты классом конфига, сложим все конфиги в словарь по имени компонента и распечатаем.

public abstract class Component<TConfig> {
    protected final TConfig config;

    public Component(TConfig config) {
        this.config = config;
    }

    abstract public void start(Cloud cloud, List<Component<?>> env);

    public TConfig getConfig() {
        return config;
    }
}
public class WorkerComponent extends Component<WorkerComponent.Config> {
    public static class Config {
        final int restPort = PortAllocator.freePort();
        private final String link = "http://localhost:" + restPort;
    }
    ...
}
public class EnvStarter {
    public static void env(Cloud cloud, Component<?>... components) {
        printConfigsToConsole(components);

        for (Component<?> component : components) {
            component.start(cloud, Arrays.asList(components));
        }
    }

    private static void printConfigsToConsole(Component<?>[] components) {
        ObjectWriter objectWriter = new ObjectMapper()
                .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
                .writerWithDefaultPrettyPrinter();

        Map<String, Object> configMap = new HashMap<>();
        for (Component<?> component : components) {
            configMap.put(component.getClass().getSimpleName(), component.getConfig());
        }

        try {
            System.out.println(objectWriter.writeValueAsString(configMap));
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

9. Останавливаем сервисы после теста

rev:7b4e07f9

Библиотека nanocloud следит, чтобы запущенные ею инстансы jvm были остановлены после остановки jvm, на которой был создан Cloud. Но если мы запустим много тестов в одной jvm (так делает, например, maven-surefire-plugin по умолчанию), то запущенные сервисы будут остановлены только после того, как все тесты пройдут. Надо их явно останавливать после теста. Можно это решить с помощью, например, JUnit Rules. А можно завернуть тест в лямбду:


@FunctionalInterface
public interface TestBlock {
    void performTest(Cloud cloud) throws Exception;
}

    public static void integrationTest(TestBlock block) {
        Cloud cloud = CloudFactory.createCloud();
        try {
            block.performTest(cloud);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            cloud.shutdown();
        }
    }

И тест тогда будет выглядеть так:

    @Test
    public void testTaskSubmission(){
        integrationTest((cloud)->{
            ClientComponent clientComponent=new ClientComponent();
            env(cloud,clientComponent,new WorkerComponent());

            // do the test
        });
    }

10. Ускоряем тесты. Настаиваем параллелизацию и настраиваем jvm на быстрый старт

rev:3cfefd2c

Если запустить 100 таких тестов, то выполнение на моем ноутбуке займет примерно 8-9 минут.

Если выполнять тесты в два потока, то выполнение займет 4-5 минут. Дальнейшее увеличение количества потоков на моем ноутбуке прироста к скорости не дает. Так что настроим два потока:


<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0-M7</version>
    <configuration>
        <argLine>--add-opens java.base/jdk.internal.loader=ALL-UNNAMED</argLine>
        <parallel>classesAndMethods</parallel>
        <threadCount>2</threadCount>
    </configuration>
</plugin>

Нам нужен быстрый старт. Похожую задачу решают те, кто пишет для serverless,
например, Optimizing AWS Lambda function performance for Java. Вроде бы лучше всего ускоряют следующие аргументы: -XX:TieredStopAtLevel=1 -Xverify:none. Задаем эти параметры для всех сервисов:

    private static void applyCommonJvmArgs(Cloud cloud){
        ViNode allNodes=cloud.node("**"); // ** значит все ноды
        allNodes.x(VX.JVM).addJvmArg("-XX:TieredStopAtLevel=1");
        allNodes.x(VX.JVM).addJvmArg("-Xverify:none");
    }

Время выполнения становится 1-2 минуты.

11. Включаем отладку для сервисов rev:2b327516

Чтобы отлаживать сервисы из IDE, нам надо:

  1. выбрать порт: делаем общего предка для всех конфигов и выбираем порт так же, как выбирали порт для http:

    public static class BaseComponentConfig {
        public final int debugPort = PortAllocator.freePort();
    }
  1. удобно подключаться к сервисам: если пользуемся IntelliJ Idea, то достаточно в консоль вывести Listening for transport dt_socket at address: 8888 и рядом с надписью появится кнопочка Attach Debugger. Добавляем линку в конфиг (пробел в конце обязателен!).

    public static class BaseComponentConfig {
        public final int debugPort = PortAllocator.freePort();
        public final String debugLink = "Listening for transport dt_socket at address: " + debugPort + " ";
    }
  1. включать дебаг только когда нужно. Я нашел вариант на StackOverflow. Спасибо Андрей@apangin.

    private static boolean detectIsDebugEnabled() {
        ThreadInfo[] infos = ManagementFactory.getThreadMXBean()
                .dumpAllThreads(false, false, 0);
        for (ThreadInfo info : infos) {
            if ("JDWP Command Reader".equals(info.getThreadName())) {
                return true;
            }
        }
        return false;
    }
  1. настраивать дебаг для всех сервисов в одном месте. Заменим Cloud на наш интерфес NodeProvider и получим такой код для сетапа теста:


@FunctionalInterface
public interface NodeProvider {
    Node getNode(String name, Component.BaseComponentConfig config);
}


@FunctionalInterface
public interface TestBlock {
    void performTest(NodeProvider nodeProvider) throws Exception;
}

    public static void integrationTest(TestBlock block) {
        Cloud cloud = CloudFactory.createCloud();
        applyCommonJvmArgs(cloud);
        NodeProvider nodeProvider = (name, config) -> {
            Node node = new Node(cloud, name);
            if (isDebugEnabled) {
                node.x(VX.JVM).addJvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:" + config.debugPort);
            }
            return node;
        };
        try {
            block.performTest(nodeProvider);
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            cloud.shutdown();
        }
    } 

12. Запускаем сервисы на удаленной машине по ssh

rev:dcf68675

Если тесты долгие или потребляют много ресурсов, можно запускать сервисы на удаленной машине. Настроить nanocloud запускать сервисы по ssh очень просто. Можно авторизоваться по паролю, можно по ключу:

    private static void configureRemoteExecution(ViNode allNodes){
        RemoteNode remoteNodeConfig=allNodes.x(RemoteNode.REMOTE);
        remoteNodeConfig.setRemoteNodeType();

        // выключаем загрузку ключей/хостов из конфиг файла, 
        // все будем настраивать явно в коде
        remoteNodeConfig.setHostsConfigFile("?na");

        remoteNodeConfig.setRemoteAccount(System.getProperty("int.tests.remote.user"));
        remoteNodeConfig.setPassword(System.getProperty("int.tests.remote.password"));
//        remoteNodeConfig.setSshPrivateKey(System.getProperty("int.tests.remote.key.path"));
        remoteNodeConfig.setRemoteHost(System.getProperty("int.tests.remote.host"));
        remoteNodeConfig.setRemoteJarCachePath("nanocloud-cache"); // куда складывать jar файлы
        remoteNodeConfig.setRemoteJavaExec(System.getProperty("int.tests.remote.java")); // где искать java
        }

Теперь можно передать правильное имя хоста в тест и тест, скорее всего пройдет. "Скорее всего", потому что, свободные порты мы ищем локально с тестом, а http сервер запускаем удаленно.

Для того чтобы выделять порты правильно, надо выполнять PortAllocator.freePort удаленно. Удобно это сделать с помощью nanocloud transparent rmi. Работает он следующим образом: если класс реализует интерфейс, который наследуется от Remote, то при сериализации вместо класса будет отправлен прокси, реализующий этот интерфейс.

В нашем случае:

public interface PortAllocator extends Remote {
    int freePort();
}

class PortAllocatorImpl implements PortAllocator {
    @Override
    public synchronized int freePort() { ...}
}

    private static PortAllocator obtainPortAllocatorFromRemoteNode() {
        Cloud serviceCloud = CloudFactory.createCloud();
        Node serviceNode = new Node(serviceCloud, "service-node");
        // настраиваем ноду на выполнение по ssh
        configureRemoteExecution(serviceNode);
        // создаем PortAllocatorImpl удаленно и получаем локальный прокси
        return serviceNode.execAndReturn(PortAllocatorImpl::new);
    }

При использовании transparent rmi надо быть осторожным с типами. Например, вот такой код упадет с ClassCastException,
потому что после сериализации-десериализации прилетит прокси, реализующее интерфейс, а не сам объект.

// java.lang.ClassCastException: class jdk.proxy2.$Proxy11 cannot be cast to class fuud.test.infra.PortAllocator$PortAllocatorImpl
PortAllocatorImpl portAllocator = serviceNode.execAndReturn(PortAllocator.PortAllocatorImpl::new);

Правильно так:

PortAllocator portAllocator = serviceNode.execAndReturn(PortAllocator.PortAllocatorImpl::new);

13. Заключение и советы по дальнейшему использованию и развитию

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

  1. Тестирование обратной совместимости: для проверки взаимодействия компонентов разных версий достаточно положить в classpath собранные в предыдущий релиз артефакты (вместо классов из target).

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

  3. Очередь сообщений можно так же эмулировать, найдя все бины с, например, @KafkaListener и дергая их через transparent rmi.

  4. Для тестирования проблем с сетью можно использовать Sniffy или написать обертку для netem.

Такой подход не заменит юнит-тесты хотя бы потому, что такие тесты занимают заметное время. С другой стороны, они могут обеспечить более стабильный мастер, отловив ошибки на ранних стадиях. А стабильный мастер - это спокойные нервы и крепкий сон по ночам.

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии20

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн