package com.ankurm.structure; import com.ankurm.structure.scan.broken.app.BrokenApp; import com.ankurm.structure.scan.fixed.FixedApp; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.WebApplicationType; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.context.ConfigurableApplicationContext; import javax.tools.*; import java.net.URI; import java.net.URL; import java.net.URLClassLoader; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; /** The two ways a project layout silently breaks component scanning. */ @ExtendWith(OutputCaptureExtension.class) class ScanTrapTests { @Test void appClassInTheWrongPackage() throws Exception { HttpClient http = HttpClient.newHttpClient(); try (Transcript t = new Transcript("05-scan-starts-at-the-app-class.txt", "The controller is found only if it is in or below the @SpringBootApplication class's package")) { for (var c : List.of( new Object[]{"broken", BrokenApp.class, "app class in scan.broken.app, controller in scan.broken.web (a sibling)"}, new Object[]{"fixed", FixedApp.class, "app class in scan.fixed, controller in scan.fixed.web (below it)"})) { try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder((Class) c[1]) .properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN").run()) { String base = "http://localhost:" + ctx.getEnvironment().getProperty("local.server.port"); HttpResponse r = http.send(HttpRequest.newBuilder(URI.create(base + "/hello")).GET().build(), HttpResponse.BodyHandlers.ofString()); t.section((String) c[2]); t.line("GET /hello -> %d", r.statusCode()); t.line("HelloController registered as a bean: %s", ctx.getBeanNamesForType(Object.class).length > 0 && java.util.Arrays.stream(ctx.getBeanDefinitionNames()).anyMatch(n -> n.equals("helloController"))); if (c[0].equals("broken")) assertThat(r.statusCode()).isEqualTo(404); else assertThat(r.statusCode()).isEqualTo(200); } } } } @Test void appClassInTheDefaultPackage(CapturedOutput output) throws Exception { String source = "import org.springframework.boot.autoconfigure.SpringBootApplication;\n" + "@SpringBootApplication\n" + "public class DefaultPackageApp {\n" + "}\n"; Path out = Files.createTempDirectory("defaultpkg"); JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); JavaFileObject file = new SimpleJavaFileObject(URI.create("string:///DefaultPackageApp.java"), JavaFileObject.Kind.SOURCE) { @Override public CharSequence getCharContent(boolean ignore) { return source; } }; boolean compiled = javac.getTask(null, null, null, List.of("-proc:none", "-d", out.toString(), "-classpath", System.getProperty("java.class.path")), null, List.of(file)).call(); assertThat(compiled).isTrue(); try (Transcript t = new Transcript("06-default-package.txt", "A @SpringBootApplication class in the default package (no package statement)")) { t.line("source compiled:"); for (String l : source.split("\n")) t.line(" %s", l); t.blank(); Class app; try (URLClassLoader cl = new URLClassLoader(new URL[]{out.toUri().toURL()}, getClass().getClassLoader())) { app = Class.forName("DefaultPackageApp", true, cl); String result; try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(app) .web(WebApplicationType.NONE).properties("spring.main.banner-mode=off").run()) { result = "context started with " + ctx.getBeanDefinitionCount() + " bean definitions"; } catch (Throwable e) { Throwable root = e; while (root.getCause() != null) root = root.getCause(); result = "context FAILED to start: " + e.getClass().getSimpleName() + " / root cause " + root.getClass().getSimpleName() + ": " + String.valueOf(root.getMessage()).lines().findFirst().orElse(""); } t.section("what Spring Boot logged about the default package"); output.getAll().lines().filter(l -> l.contains("default package") || l.contains("WARNING")) .map(l -> l.replaceFirst("^\\S+\\s+", "")).forEach(t::line); t.section("result"); t.line(result); } assertThat(output.getAll()).contains("default package"); } } }