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

Будем решать проблему последовательно, по шагам преодолевая возникающие трудности.
Код можно найти на 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.

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

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


  1. xeeaax
    14.08.2022 18:31
    +1

    Если нужно тестировать два микросервиса в связке, но не систему/подсистему целиком, то может лучше было делать один микросервис?


    1. Fuud Автор
      14.08.2022 18:52
      +1

      Да, если какая-то логика распределена по подсистеме, то лучше (а на каком-то уровне тестирования даже необходимо) тестировать её целиком. При этом в этой подсистеме может быть разное количество сервисов. Если количество сервисов больше одного, то можно применить подход, описанный в статье.

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


      1. xeeaax
        14.08.2022 19:20
        +1

        Звучит опасно - сначала тестируем конкретную связку, а потом "разный деплой".

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


        1. Fuud Автор
          15.08.2022 06:57

          Кажется, я Вас не понимаю. Можете раскрыть подробнее, что кажется вам опасным?


          1. xeeaax
            15.08.2022 10:20

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


            1. Fuud Автор
              15.08.2022 10:56

              Понял.
              Я не предлагаю выдирать микросервисы из подсистемы. Если ваша подсистема включает, скажем, десять микросервисов - так и тестируйте десять микросервисов. В предлагаемом подходе нигде нет ограничений на количество (кроме ресурсов машины, на которой исполняете).

              В целом этот подход ничем не отличается от обычных тестов на подсистему, но дает два преимущества: проще запускать из ide при разработке в рамках TDD + возможность прогонять тесты на этапе пулл-реквеста, не деплоя на энв.


              1. remal
                15.08.2022 12:25

                Хорошая архитектура в глобальном смысле - не только о возможностях, но и об ограничениях и посылах.

                Тут вы даете возможность, но что насчет ограничений?

                Давайте приведу вот еще такой пример: цель - деплоить несколько раз в день. Не в конце спринта, если у вас Скрам, а вот реально несколько раз в день. Одна эта цель меняет все - от стиля написания кода и размеров pull request'ов, до способа управления проектом (Скрам уже не подходит).


  1. remal
    15.08.2022 00:32
    +2

    Автор, ваш подход, наверное, будет работать на вашем конеретном проекте, но читателей я прошу до последнего так не делать.

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

    Вы выше упоминаете независимый деплой. На мой взгляд, независимый деплой возможен только при независимой разработке. У меня есть определенные подозрения, что в данном случае с независимой разработкой так себе. Я не авторитет? Без проблем, давайте посмотрим что говорят Sam Newman и Martin Fowler: https://youtu.be/GBTdnfD6s5Q

    К чему подобные тесты приводят в long-term: нестабильные тесты, огромные затраты на поиск причины свалившегося теста и изменение кучи тестов при выкатывании новых фич. Надеюсь, у вас получается этого избежать.


    1. Fuud Автор
      15.08.2022 07:04

      Простите, не понимаю. Вы против сквозного тестирования? Вы против интеграционного тестирования? Вы против того, чтобы один микросервис обращался к другому?

      Если ответ на последний вопрос "нет, не против", то как и в какой момент можно убедиться, что все правильно работает?


      1. remal
        15.08.2022 09:27
        +1

        Понимаете, способов обеспечить качество много и парой видов тестов все не ограничивается.

        Я за то, чтобы воспринимать другой (микро)сервис как совершенно внешний, который делает другая компания со своим релизным циклом, на незнакомым вам языке программирования и доступа к исходникам у вас нет. Стремление к этому и делает деплой/разработку по настоящему независимым. Вот что вы тогда будете делать?

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

        На нескольких проектах я задавал такой вопрос. У нас есть отдельно протестированный сервис А и отдельно протестированный сервис B, где взаимодействие с сервисом A замокано. Зачем нам тестировать еще и end-to-end сценарии, если есть строгие контракты? Что мы тестируем вообще? Бизнес логика протестирована изолировано. Конфигурацию и инфраструктуру? Так это не сквозным тестированием проверить можно. Тогда что? Просто повторим наши юнит тесты? Смысл? Четстного ответа "мы не доверяем разработчикам" я так и не дожидался, хотя по косвенным признакам было понятно, что проблема где-то тут.

        А вот по Testcontainers для баз, например, я полностью поддерживаю, кстати.


        1. Fuud Автор
          15.08.2022 10:01

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

          Но да, я не доверяю разработчикам. Я сам разработчик и я себе не доверяю. Я подозреваю себя в том, что:

          1. я плохо понял контракт

          2. я недостаточно покрыл тестами граничные условия, а по границам может пролегать success story

          3. мокая другой сервис, я не включил в ответы все возможные значения

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


          1. remal
            15.08.2022 12:04

            Понимаете какая штука... А вам точно нужно протестировать вот прям все сценарии с граничными условиями?

            Ресурсы же не бесконечны и очень часто приходится расставлять приоритеты и чем-то жертвовать. Настолько ли ваша система критична, что даже час-два, к примеру, простоя вызовет огромные денежные потери для компании? Точно ли вам надо тратить пару человеко-месяцев на разработку и отладку такого решения, а не фичи пилить?

            И проблема-то не только в единожды затраченом времени. Проблема еще и в том, что это все надо поддерживать, рассказывать новичкам, ревьюить, чинить нестабильные тесты (а они будут), иметь долгое время сборки, баги будут чиниться намного сложнее и дольше, чем при помощи юнит тестов и т.п. А, кроме того, наличие такого решения будет побуждать им пользоваться, писать еще больше интеграционных тестов и постепенно усугублять проблемы.

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

            Проблема в том, что, возможно (!), вы чинили не причину, а следствие. Отсутствие доверия, в т.ч. и к самому себе, - это такая же проблема, как упавший тест или баг в продакшене. И ровно так же эта проблема может быть решена. Только смотреть тут надо на коммуникацию, процессы, архитектуру, метрики качества, насколько детальный анализ причин каждого бага проводится и т.д. Для инженера это сложно и непривычно, знаю по себе.


            1. Fuud Автор
              15.08.2022 12:23

              А вам точно нужно протестировать вот прям все сценарии с граничными условиями?

              Как раз нет. Я как раз про то, что со сквозными тестами нет нужды проверять все. А вот с юнит-тестами и контрактом как раз напротив. Если "воспринимать другой (микро)сервис как совершенно внешний", то и ожидать от него можно все, что угодно. У юнит-тестов плюс - быстрое исполнение - это Вы верно заметили. Но как понять, что он проверяет сценарий, которые случается и как понять, что сценарии, которые случаются - покрыты тестами?

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

              Да. Я сделал решение, чтобы им пользовались. Это не серебрянная пуля. Она позволяет махом проверить систему, но у нее есть цена. Для проверки одного микросервиса есть, например, @SpringBootTest, для проверки класса или функции - достаточно обычных тестов. У каждого типа - своя ниша и своя цена.

              Мне кажется, что мы перешли от обсуждения конкретного решения к обсуждению, нужны ли end-to-end тесты вообще. Это так?


              1. remal
                15.08.2022 13:23

                Ожидать от внешнего сервиса можно что угодно, да. И то, что контракт может не исполняться. Проблема в том, что есть вещи в зоне нашей отвественности, а есть те, о которых мы должны знать (что они могут происходить), но вне нашей зоне ответственности. Вот оказалась на сервере битая плата оперативы. Вы это явно не тестами проверять будете, особенно если в облаке хоститесь. Соответственно, вкладываясь в интеграционные тесты, вы не вкладываетесь в observability и укорачавание релизного цикла (ресуры не бесконечны).

                Как проверить какие сценарии покрыты тестами, а какие - нет? В общем случае гарантировать это невозможно. На уровне юнит тестов - code review. На уровне системы - а точно надо? Но даже если и надо, то, имхо, первичный вопрос - что делать с багами, найденными там? Я бы каждый найденный там баг спускал бы в команду разработки с вопросом как починить причину его возникновения (чек листы на code review, рефакторинг, архитектура, тренинги, ...) А вопрос как тестировать end-to-end для меня вторичен. Да хоть руками, если это экономически выгоднее.

                Проблема в том, что качество не мерится только лишь процентом покрытия. На одном из проектов я вот прям и говорил: граничные условия покрываем только если есть свободное время. Это немного утрированно, но посыл был примерно такой, о приоритетах. Тем не менее, проблем с качеством было крайне мало и 90% багов было связано с плохим пониманием контрактов, что надо было решать не тестами, а привлечением аналитика. Ресурсов на аналитика не было, тч потерпели первые пару месяцев в продакшене, тестируя руками, дальше полет нормальный, все довольны. Но, да, мы использовали строгие контракты везде, где можно, много кодогенерации, задали высокую планку для code review, у нас в каждый pull request автоматом отправлялся check list, а также писали кучу правил для статического анализа (см ArchUnit).

                Плюс юнит тестов не сколько в быстром исполнении, сколько в локализованности - вы очень быстро найдете где конкретно падает. Чем выше мы поднимаемся по пирамиде тестирования, тем дольше это время. На нынешнем проекте я месяца полтора убеждал заказчика писать меньше интеграционных тестов и больше юнит - команда разработки тратила 10-15% времени на починку и разбор нестабильных интеграционных тестов. Сейчас стало лучше.

                Давайте честно: с тз читателя вы сделали не решение, а примерно описали в каком направлении копать и ряд подводных камней. Повторить ваше решение даже по статье сможет достаточно небольшой процент разработчиков. Это не претензия, если что. Кстати, я звездочками пометил пару репозиториев на github, за это спасибо. Проблема, на мой взгляд, что очень многие все еще говорят о том, как делать микросервисы, как писать интеграционные или end-to-end тесты и тд, но очень мало рассказов о том, как это не делать. А "не делать" - это не просто не делать. Это - о том, как не делая достигать поставленных целей. А тут и проблемы с доверием, и сбор бизнес метрик, и куча всего.

                На тему end-to-end тестов вот неделю назад как раз читал: https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html?m=1 Но это - Гугл, они спокойно на пользователях в проде тестируют. Лично я выпускал успешные проекты без единого автоматизированного end-to-end, т.ч., да, я готов ставить под сомнение их целесообразность. И, если что, в моем понимании, интеграционные тесты - testcontainers, sql проверить, например. А ваше решение для меня намного ближе к end-to-end.

                Я изначально свою ветку начал с того, что призвал читателей так не делать. Т.е. все это обсуждение - о целесообразности. Это не значит, что статья бесполезная, если что. Мой посыл, скорее, - почитайте, узнайте что-то новое, но 10 раз подумайте точно ли оно вам надо, перед тем, как копировать.


  1. Mayari
    15.08.2022 07:08

    Для какого уровня подготовки эта статья?


    1. Fuud Автор
      15.08.2022 07:13

      Не знаю. Наверное для тех, кому не понравился цикл коммит/аппрув/мерж/деплой/прогон тестов/бага/возврат в начало. По сложности реализации у себя в проекте: наверное жунам будет сложно, но middle уже должен справиться.


  1. remal
    15.08.2022 13:42

    Немного по тексту статьи.

    Для проверки на доступность порта я пользовался примерно таким кодом: https://stackoverflow.com/a/48828373 (см setReuseAddress)

    Для проверки на дебаггер:

    boolean IS_IN_DEBUG = ManagementFactory.getRuntimeMXBean().getInputArguments().toString().contains("jdwp");
    

    Возможно, менее универсально, чем у вас, но мне хватало.


  1. vladimir_bukhtoyarov
    15.08.2022 22:44

    Тоже активно использую Nanocloud, но в менее глобальном контексте, конкретно для тестирования распределенных вычеслений для Hazelcast и ApacheIgnite.