How to start multiple boot apps for end-to-end tests?

Viewed 326

I'd like to write end-to-end tests to validate two boot apps work well together with various profiles.

What already works:

  • create a third maven module (e2e) for end-to-end tests, in addition to the two tested apps (authorization-server and resource-server)
  • write tests using TestResTemplate

Test work fine if I start authorization-server and resource-server manually.

What I now want to do is automate the tested boot apps startup and shutdown with the right profiles for each test.

I tried:

  • adding maven dependencies to tested apps in e2e module
  • using SpringApplication in new threads for each app to start

But I face miss-configuration issues as all resources and dependencies end in the same shared classpath...

Is there a way to sort this out?

I'm also considering starting two separate java -jar ... processes, but then, how to ensure tested apps fat-jars are built before 2e2 unit-tests run?

Current app start/shutdown code sample which fails as soon as I had maven dependency to second app to start:

    private Service startAuthorizationServer(boolean isJwtActive) throws InterruptedException {
        return new Service(
                AuthorizationServer.class,
                isJwtActive ? new String[]{ "jwt" } : new String[]{} );
    }

    private static final class Service {
        private ConfigurableApplicationContext context;
        private final Thread thread;

        public Service(Class<?> appClass, String... profiles) throws InterruptedException {
            thread = new Thread(() -> {
                SpringApplication app = new SpringApplicationBuilder(appClass).profiles(profiles).build();
                context = app.run();

            });
            thread.setDaemon(false);
            thread.start();
            while (context == null || !context.isRunning()) {
                Thread.sleep(1000);
            };
        }

        @PreDestroy
        public void stop() {
            if (context != null) {
                SpringApplication.exit(context);
            }
            if (thread != null) {
                thread.interrupt();
            }
        }
    }
2 Answers

I got things working with second solution:

  • end-to-end tests projects has no other maven dependency than what is required to run spring-tests with TestRestClient
  • test config initialises environment, running mvn packageon required modules in separate processes
  • test cases run (re)start apps with chosen profiles in separate java -jar ... processes

Here is the helper class I wrote for this (taken from there):

class ActuatorApp {
    private final int port;
    private final String actuatorEndpoint;
    private final File jarFile;
    private final TestRestTemplate actuatorClient;
    private Process process;

    private ActuatorApp(File jarFile, int port, TestRestTemplate actuatorClient) {
        this.port = port;
        this.actuatorEndpoint = getBaseUri() + "actuator/";
        this.actuatorClient = actuatorClient;
        this.jarFile = jarFile;

        Assert.isTrue(jarFile.exists(), jarFile.getAbsolutePath() + " does not exist");
    }

    public void start(List<String> profiles, List<String> additionalArgs) throws InterruptedException, IOException {
        if (isUp()) {
            stop();
        }

        this.process = Runtime.getRuntime().exec(appStartCmd(jarFile, profiles, additionalArgs));

        Executors.newSingleThreadExecutor().submit(new ProcessStdOutPrinter(process));

        for (int i = 0; i < 10 && !isUp(); ++i) {
            Thread.sleep(5000);
        }
    }

    public void start(String... profiles) throws InterruptedException, IOException {
        this.start(Arrays.asList(profiles), List.of());
    }

    public void stop() throws InterruptedException {
        if (isUp()) {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
            headers.setAccept(List.of(MediaType.APPLICATION_JSON_UTF8));

            actuatorClient.postForEntity(actuatorEndpoint + "shutdown", new HttpEntity<>(headers), Object.class);
            Thread.sleep(5000);
        }
        if (process != null) {
            process.destroy();
        }
    }

    private String[] appStartCmd(File jarFile, List<String> profiles, List<String> additionalArgs) {
        final List<String> cmd = new ArrayList<>(
                List.of(
                        "java",
                        "-jar",
                        jarFile.getAbsolutePath(),
                        "--server.port=" + port,
                        "--management.endpoint.heath.enabled=true",
                        "--management.endpoint.shutdown.enabled=true",
                        "--management.endpoints.web.exposure.include=*",
                        "--management.endpoints.web.base-path=/actuator"));
        if (profiles.size() > 0) {
            cmd.add("--spring.profiles.active=" + profiles.stream().collect(Collectors.joining(",")));
        }
        if (additionalArgs != null) {
            cmd.addAll(additionalArgs);
        }
        return cmd.toArray(new String[0]);
    }

    private boolean isUp() {
        try {
            final ResponseEntity<HealthResponse> response =
                    actuatorClient.getForEntity(actuatorEndpoint + "health", HealthResponse.class);
            return response.getStatusCode().is2xxSuccessful() && response.getBody().getStatus().equals("UP");
        } catch (ResourceAccessException e) {
            return false;
        }
    }

    public static Builder builder(String moduleName, String moduleVersion) {
        return new Builder(moduleName, moduleVersion);
    }

    /**
     * Configure and build a spring-boot app
     *
     * @author Ch4mp
     *
     */
    public static class Builder {

        private String moduleParentDirectory = "..";

        private final String moduleName;

        private final String moduleVersion;

        private int port = SocketUtils.findAvailableTcpPort(8080);

        private String actuatorClientId = "actuator";

        private String actuatorClientSecret = "secret";

        public Builder(String moduleName, String moduleVersion) {
            this.moduleName = moduleName;
            this.moduleVersion = moduleVersion;
        }

        public Builder moduleParentDirectory(String moduleParentDirectory) {
            this.moduleParentDirectory = moduleParentDirectory;
            return this;
        }

        public Builder port(int port) {
            this.port = port;
            return this;
        }

        public Builder actuatorClientId(String actuatorClientId) {
            this.actuatorClientId = actuatorClientId;
            return this;
        }

        public Builder actuatorClientSecret(String actuatorClientSecret) {
            this.actuatorClientSecret = actuatorClientSecret;
            return this;
        }

        /**
         * Ensures the app module is found and packaged
         * @return app ready to be started
         * @throws IOException if module packaging throws one
         * @throws InterruptedException if module packaging throws one
         */
        public ActuatorApp build() throws IOException, InterruptedException {
            final File moduleDir = new File(moduleParentDirectory, moduleName);

            packageModule(moduleDir);

            final File jarFile = new File(new File(moduleDir, "target"), moduleName + "-" + moduleVersion + ".jar");

            return new ActuatorApp(jarFile, port, new TestRestTemplate(actuatorClientId, actuatorClientSecret));
        }

        private void packageModule(File moduleDir) throws IOException, InterruptedException {
            Assert.isTrue(moduleDir.exists(), "could not find module. " + moduleDir + " does not exist.");

            String[] cmd = new File(moduleDir, "pom.xml").exists() ?
                    new String[] { "mvn", "-DskipTests=true", "package" } :
                    new String[] { "./gradlew", "bootJar" };

            Process mvnProcess = new ProcessBuilder().directory(moduleDir).command(cmd).start();
            Executors.newSingleThreadExecutor().submit(new ProcessStdOutPrinter(mvnProcess));

            Assert.isTrue(mvnProcess.waitFor() == 0, "module packaging exited with error status.");
        }
    }

    private static class ProcessStdOutPrinter implements Runnable {
        private InputStream inputStream;

        public ProcessStdOutPrinter(Process process) {
            this.inputStream = process.getInputStream();
        }

        @Override
        public void run() {
            new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(System.out::println);
        }
    }

    public String getBaseUri() {
        return "https://localhost:" + port;
    }
}
Related