This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 90def896f8 feat: @MicroserviceTest JUnit 5 extension for
whole-microservice integration tests
90def896f8 is described below
commit 90def896f840463deb42641172616eb8d7834138
Author: James Bognar <[email protected]>
AuthorDate: Fri Jun 19 09:16:54 2026 -0400
feat: @MicroserviceTest JUnit 5 extension for whole-microservice
integration tests
---
juneau-bom/pom.xml | 1 +
.../juneau/junit5/JuneauBeanStoreExtension.java | 55 +++++
.../juneau-microservice-test/pom.xml | 157 ++++++++++++
.../test/EphemeralJettyServerConfig.java | 61 +++++
.../microservice/test/MicroserviceExtension.java | 265 +++++++++++++++++++++
.../juneau/microservice/test/MicroserviceTest.java | 139 +++++++++++
.../juneau/microservice/test/package-info.java | 34 +++
.../test/MicroserviceTestBuilderSupplier_Test.java | 70 ++++++
.../microservice/test/MicroserviceTest_Test.java | 116 +++++++++
juneau-microservice/pom.xml | 1 +
scripts/check-container-tags.py | 4 +-
11 files changed, 902 insertions(+), 1 deletion(-)
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index b76bb3b0e2..40d6492361 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -80,6 +80,7 @@
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice-jetty</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice-tomcat</artifactId><version>${project.version}</version></dependency>
+
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice-test</artifactId><version>${project.version}</version></dependency>
<!--
=====================================================================================
-->
<!-- juneau-bean
-->
diff --git
a/juneau-core/juneau-junit5/src/main/java/org/apache/juneau/junit5/JuneauBeanStoreExtension.java
b/juneau-core/juneau-junit5/src/main/java/org/apache/juneau/junit5/JuneauBeanStoreExtension.java
index aeaef5ea32..4d6278ce3f 100644
---
a/juneau-core/juneau-junit5/src/main/java/org/apache/juneau/junit5/JuneauBeanStoreExtension.java
+++
b/juneau-core/juneau-junit5/src/main/java/org/apache/juneau/junit5/JuneauBeanStoreExtension.java
@@ -418,6 +418,61 @@ public class JuneauBeanStoreExtension implements
BeforeAllCallback, AfterAllCall
return new ScopedStore(store, modeTracker.resolve(),
modeTracker.empty());
}
+ /**
+ * Discovers the {@code @TestBean} overrides declared on a test
instance (and its class hierarchy) and returns
+ * them as a single {@link OverrideSet} — the public composition
entry point for other JUnit 5 extensions
+ * (e.g. {@code @MicroserviceTest}) that need the {@code @TestBean}
substrate without driving this extension's
+ * own lifecycle.
+ *
+ * <p>
+ * Both {@code @TestBean(scope = CLASS)} (static) and {@code
@TestBean(scope = METHOD)} (instance) declarations
+ * are collected into one flattened overlay (method-scope chained over
class-scope), so a composing extension
+ * that boots a single system-under-test per scope sees every declared
override. The returned
+ * {@link OverrideSet#mode()} is the unified mode of all declarations
(defaulting to {@link Mode#INJECT} when
+ * none are present); mixing {@code INJECT} and {@code OVERLAY} in one
scope throws {@link IllegalStateException}.
+ *
+ * @param testInstance The test-class instance to scan. Must not be
<jk>null</jk>.
+ * @return The discovered overrides + their unified mode. Never
<jk>null</jk>; {@link OverrideSet#isEmpty()} is
+ * <jk>true</jk> when no {@code @TestBean} members were found.
+ * @since 10.0.0
+ */
+ public static OverrideSet discoverOverrides(Object testInstance) {
+ var classScoped =
buildClassScopeStoreWithMode(testInstance.getClass());
+ var methodScoped = buildMethodScopeStoreWithMode(testInstance,
classScoped.store());
+ // Unify the two scopes' modes: an empty scope contributes no
constraint; a populated one does.
+ var mode = methodScoped.empty() ? classScoped.mode() :
methodScoped.mode();
+ if (! classScoped.empty() && ! methodScoped.empty() &&
classScoped.mode() != methodScoped.mode())
+ throw new IllegalStateException(
+ "Mixed @TestBean modes across scopes: CLASS
declares Mode." + classScoped.mode()
+ + " but METHOD declares Mode." +
methodScoped.mode()
+ + ". All @TestBean declarations must use the
same mode for a single-boot SUT.");
+ var empty = classScoped.empty() && methodScoped.empty();
+ return new OverrideSet(methodScoped.store(), mode, empty);
+ }
+
+ /**
+ * The flattened result of {@link #discoverOverrides(Object)}: the
{@code @TestBean} overlay store, the unified
+ * {@link Mode} of all declarations, and whether any were found.
+ *
+ * @param store The overlay {@link BeanStore} carrying the discovered
overrides (method-scope chained over
+ * class-scope). Usable directly as a builder {@code
overridingBeanStore(...)} argument for {@link Mode#INJECT},
+ * or as a {@code pushOverlay(...)} argument for {@link
Mode#OVERLAY}.
+ * @param mode The unified declaration mode ({@link Mode#INJECT} by
default).
+ * @param empty Whether no {@code @TestBean} members were discovered.
+ * @since 10.0.0
+ */
+ public record OverrideSet(TestBeanStore store, Mode mode, boolean
empty) {
+
+ /**
+ * Returns whether no {@code @TestBean} members were discovered.
+ *
+ * @return <jk>true</jk> if the override set is empty.
+ */
+ public boolean isEmpty() {
+ return empty;
+ }
+ }
+
/**
* Walks the test-class hierarchy and registers every {@code
@TestBean(scope = CLASS)} declared on a
* {@code static} field or {@code static} method.
diff --git a/juneau-microservice/juneau-microservice-test/pom.xml
b/juneau-microservice/juneau-microservice-test/pom.xml
new file mode 100644
index 0000000000..64bd85b038
--- /dev/null
+++ b/juneau-microservice/juneau-microservice-test/pom.xml
@@ -0,0 +1,157 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-microservice-parent</artifactId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-microservice-test</artifactId>
+ <name>Apache Juneau Microservice Test Support</name>
+ <description>Apache Juneau JUnit 5 @MicroserviceTest extension - boots
a whole Microservice (Jetty, ephemeral port) for integration tests with
@TestBean mock injection.</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>org.eclipse.jetty</groupId>
+ <artifactId>jetty-bom</artifactId>
+ <version>${jetty.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
+ <dependencies>
+ <!-- The JUnit 5 mock-bean substrate (@TestBean / TestBeanStore
/ discovery) this extension composes. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-junit5</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <!-- Boots a real embedded-Jetty Microservice on an ephemeral
port (Jetty-only for v1). -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-microservice-jetty</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <!-- Real HTTP client injected into tests, bound to the booted
server's root URL. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-client</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <!-- Jetty Server / ServerConnector used by the
ephemeral-server config + bound-port probe. -->
+ <dependency>
+ <groupId>org.eclipse.jetty</groupId>
+ <artifactId>jetty-server</artifactId>
+ </dependency>
+ <dependency>
+
<groupId>org.eclipse.jetty.ee${jetty.ee.version}</groupId>
+
<artifactId>jetty-ee${jetty.ee.version}-servlet</artifactId>
+ <version>${jetty.version}</version>
+ </dependency>
+ <!-- JUnit Jupiter API is provided - the consuming test module
supplies the engine. -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <version>${junit.version}</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+
<supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+
<goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git
a/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/EphemeralJettyServerConfig.java
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/EphemeralJettyServerConfig.java
new file mode 100644
index 0000000000..60f12d5c8b
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/EphemeralJettyServerConfig.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microservice.test;
+
+import org.apache.juneau.commons.inject.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+
+/**
+ * A {@code @Configuration} that supplies a Jetty {@link Server} bound to an
OS-assigned ephemeral port
+ * (port {@code 0}) for use by {@link MicroserviceExtension
@MicroserviceTest}-driven integration tests.
+ *
+ * <p>
+ * The server has a single {@link ServerConnector} on port 0 and a single root
{@link ServletContextHandler}
+ * at context path {@code "/"}. {@code JettyServerComponent} discovers the
handler via the
+ * {@code "ServletContextHandler"} server attribute (the same convention used
by {@code jetty.xml}).
+ *
+ * <p>
+ * The extension installs this {@code @Configuration} automatically after the
user-supplied configurations, so
+ * a user-supplied {@code @Bean Server} (if any) still wins (the bean store
returns the first registered match).
+ * Tests supply their {@code @Bean Servlet} definitions, which {@code
JettyServerComponent} auto-mounts at the
+ * resource's {@code @Rest(path=...)}.
+ *
+ * @since 10.0.0
+ */
+@Configuration
+public class EphemeralJettyServerConfig {
+
+ /**
+ * Provides the bean-supplied Jetty {@link Server} that {@code
JettyServerComponent} consumes during
+ * {@code onStart()}.
+ *
+ * @return A configured {@link Server} bound to port 0 (OS-assigned
ephemeral port).
+ */
+ @Bean
+ public Server jettyServer() {
+ var server = new Server();
+ var connector = new ServerConnector(server);
+ connector.setPort(0);
+ server.addConnector(connector);
+ var sch = new ServletContextHandler();
+ sch.setContextPath("/");
+ server.setAttribute("ServletContextHandler", sch);
+ server.setHandler(sch);
+ return server;
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceExtension.java
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceExtension.java
new file mode 100644
index 0000000000..53dab93b02
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceExtension.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microservice.test;
+
+import java.lang.reflect.*;
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.junit5.*;
+import org.apache.juneau.junit5.JuneauBeanStoreExtension.OverrideSet;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.microservice.jetty.*;
+import org.apache.juneau.rest.client.*;
+import org.eclipse.jetty.server.*;
+import org.junit.jupiter.api.extension.*;
+
+/**
+ * JUnit 5 extension behind {@link MicroserviceTest @MicroserviceTest}: boots
a whole
+ * {@link Microservice} (config + lifecycle + embedded Jetty on an ephemeral
port) for the test class, with
+ * {@link org.apache.juneau.junit5.TestBean @TestBean} mock-bean injection and
convenience parameter resolution.
+ *
+ * <h5 class='topic'>Lifecycle (per class)</h5>
+ *
+ * <p>
+ * The microservice is built + {@link Microservice#start() started} once in
{@code beforeAll} and
+ * {@link Microservice#stop() stopped} in {@code afterAll}. A fresh instance
is booted per class and stopped
+ * cleanly — never reused, since {@code Microservice} restart is
unsupported (the {@code stopped} flag is
+ * one-way, the bean store is closed, and each {@code start()} adds a JVM
shutdown hook). The bound port is read
+ * from the live {@link ServerConnector} (never hard-coded), mirroring the
established fixture pattern.
+ *
+ * <h5 class='topic'>Mock-bean injection</h5>
+ *
+ * <p>
+ * {@link org.apache.juneau.junit5.TestBean @TestBean} declarations are
discovered via
+ * {@link JuneauBeanStoreExtension#discoverOverrides(Object)}. <b>Mode
INJECT</b> (the default) installs the
+ * overrides via {@code Microservice.Builder.overridingBeanStore(...)}
<i>before</i> boot, so the service reads
+ * them from startup. <b>Mode OVERLAY</b> pushes the overrides onto the booted
instance's bean store for the
+ * class duration and pops them in teardown.
+ *
+ * <h5 class='topic'>Parameter resolution</h5>
+ *
+ * <p>
+ * Resolves {@link RestClient} (bound to the booted root URL), {@link
Microservice}, {@link WritableBeanStore},
+ * and the bound port ({@code int} / {@code Integer}) on test + lifecycle
method parameters.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+ "resource", // The Microservice + RestClient are owned by this
extension and closed/stopped in afterAll(); not unmanaged leaks.
+ "java:S3011" // setAccessible on the test class's no-arg ctor /
builder-supplier method is intentional - the same reflective access JUnit
itself uses to drive user test classes.
+})
+public class MicroserviceExtension implements BeforeAllCallback,
AfterAllCallback, ParameterResolver {
+
+ /** Default name of the optional {@code static Microservice.Builder}
supplier method on the test class. */
+ public static final String BUILDER_SUPPLIER_METHOD =
"microserviceBuilder";
+
+ private static final ExtensionContext.Namespace NAMESPACE =
ExtensionContext.Namespace.create(MicroserviceExtension.class);
+ private static final String KEY_STATE = "state";
+
+ /** Per-class booted state. */
+ private static final class State {
+ Microservice microservice;
+ RestClient client;
+ URI rootUrl;
+ WritableBeanStore beanStore;
+ Snapshot overlaySnapshot; // non-null when Mode OVERLAY was
pushed
+ }
+
+ @Override /* BeforeAllCallback */
+ public void beforeAll(ExtensionContext context) throws Exception {
+ var testClass = context.getRequiredTestClass();
+ var ann = findAnnotation(testClass);
+
+ // Discover @TestBean overrides from the test class (static
members participate at class-boot time).
+ var instance = instantiateForDiscovery(testClass);
+ var overrides =
JuneauBeanStoreExtension.discoverOverrides(instance);
+
+ var builder = resolveBuilder(testClass, ann);
+
+ // The user's configurations come first (so @Bean Servlet
methods are visible), then the ephemeral Jetty
+ // server (port 0), then JettyConfiguration wires the
lifecycle. BeanStore returns the first registered
+ // match, so a user-supplied @Bean Server still wins.
+ builder.configurations(ann.configurations());
+ builder.configurations(EphemeralJettyServerConfig.class,
JettyConfiguration.class);
+
+ var injecting = ! overrides.isEmpty() && overrides.mode() ==
Mode.INJECT;
+ if (injecting)
+ builder.overridingBeanStore(overrides.store());
+
+ var state = new State();
+ state.microservice = builder.build();
+ state.microservice.start();
+ state.beanStore = state.microservice.getBeanStore();
+ state.rootUrl = resolveRootUrl(state.microservice);
+ state.client =
RestClient.builder().rootUrl(state.rootUrl.toString()).build();
+
+ // Mode OVERLAY: push the overrides onto the booted instance's
bean store for the class duration.
+ if (! overrides.isEmpty() && overrides.mode() == Mode.OVERLAY)
+ state.overlaySnapshot =
state.beanStore.pushOverlay(overrides.store());
+
+ context.getStore(NAMESPACE).put(KEY_STATE, state);
+ }
+
+ @Override /* AfterAllCallback */
+ public void afterAll(ExtensionContext context) {
+ var state = (State)
context.getStore(NAMESPACE).remove(KEY_STATE);
+ if (state == null)
+ return;
+ try {
+ if (state.overlaySnapshot != null)
+
state.beanStore.popOverlay(state.overlaySnapshot);
+ } finally {
+ try {
+ if (state.client != null)
+ state.client.close();
+ } catch (Exception e) { // HTT: RestClient.close()
failure is not reproducible against the in-process JDK transport.
+ // Best-effort close; never mask teardown.
+ } finally {
+ if (state.microservice != null)
+ safeStop(state.microservice);
+ }
+ }
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // ParameterResolver
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Override /* ParameterResolver */
+ public boolean supportsParameter(ParameterContext pc, ExtensionContext
ec) {
+ var t = pc.getParameter().getType();
+ return t == RestClient.class || t == Microservice.class || t ==
WritableBeanStore.class
+ || t == int.class || t == Integer.class;
+ }
+
+ @Override /* ParameterResolver */
+ public Object resolveParameter(ParameterContext pc, ExtensionContext
ec) {
+ var state = readState(ec);
+ if (state == null)
+ throw new
ParameterResolutionException("@MicroserviceTest has no booted microservice to
resolve from.");
+ var t = pc.getParameter().getType();
+ if (t == RestClient.class)
+ return state.client;
+ if (t == Microservice.class)
+ return state.microservice;
+ if (t == WritableBeanStore.class)
+ return state.beanStore;
+ if (t == int.class || t == Integer.class)
+ return state.rootUrl.getPort();
+ throw new ParameterResolutionException("Unsupported
@MicroserviceTest parameter type: " + t.getName());
+ }
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // Helpers
+
//-----------------------------------------------------------------------------------------------------------------
+
+ private static MicroserviceTest findAnnotation(Class<?> testClass) {
+ for (var c = testClass; c != null; c = c.getSuperclass()) {
+ var a = c.getAnnotation(MicroserviceTest.class);
+ if (a != null)
+ return a;
+ }
+ throw new ExtensionContextException("@MicroserviceTest
annotation not found on " + testClass.getName());
+ }
+
+ private static Microservice.Builder resolveBuilder(Class<?> testClass,
MicroserviceTest ann) {
+ var m = findBuilderSupplier(testClass, ann.builderMethod());
+ if (m == null)
+ return Microservice.create();
+ try {
+ m.setAccessible(true);
+ var result = m.invoke(null);
+ if (! (result instanceof Microservice.Builder b))
+ throw new ExtensionContextException(
+ "@MicroserviceTest builder method '" +
ann.builderMethod() + "' on " + testClass.getName()
+ + " must return a
Microservice.Builder.");
+ return b;
+ } catch (ReflectiveOperationException e) {
+ throw new ExtensionContextException(
+ "Failed to invoke @MicroserviceTest builder
method '" + ann.builderMethod() + "' on " + testClass.getName(), e);
+ }
+ }
+
+ private static Method findBuilderSupplier(Class<?> testClass, String
name) {
+ for (var c = testClass; c != null; c = c.getSuperclass()) {
+ try {
+ var m = c.getDeclaredMethod(name);
+ if (Modifier.isStatic(m.getModifiers()))
+ return m;
+ throw new ExtensionContextException(
+ "@MicroserviceTest builder method '" +
name + "' on " + c.getName() + " must be static.");
+ } catch (NoSuchMethodException e) {
+ // Try the superclass.
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Instantiates the test class via its no-arg constructor purely to
drive {@code @TestBean} discovery (which
+ * reads instance + static members). Falls back to a
hierarchy-static-only scan if no usable no-arg ctor exists.
+ */
+ private static Object instantiateForDiscovery(Class<?> testClass) {
+ try {
+ var ctor = testClass.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ return ctor.newInstance();
+ } catch (ReflectiveOperationException e) {
+ // No accessible no-arg ctor (e.g.
@TestInstance(PER_CLASS) with constructor injection). Static-only
+ // @TestBean discovery still works via a throwaway
minimal instance of Object's identity is impossible,
+ // so re-raise with guidance.
+ throw new ExtensionContextException(
+ "@MicroserviceTest requires a no-arg
constructor on " + testClass.getName()
+ + " to discover @TestBean overrides before
boot.", e);
+ }
+ }
+
+ private static URI resolveRootUrl(Microservice microservice) {
+ var component =
microservice.getBeanStore().getBean(JettyServerComponent.class).orElseThrow(
+ () -> new ExtensionContextException("@MicroserviceTest
could not find a JettyServerComponent after start()."));
+ var server = component.getServer();
+ var localPort = -1;
+ for (var c : server.getConnectors()) {
+ if (c instanceof ServerConnector sc) {
+ localPort = sc.getLocalPort();
+ break;
+ }
+ }
+ if (localPort <= 0)
+ throw new ExtensionContextException("@MicroserviceTest
could not determine the bound ServerConnector port after start().");
+ return URI.create("http://localhost:" + localPort);
+ }
+
+ private static void safeStop(Microservice microservice) {
+ try {
+ microservice.stop();
+ } catch (Exception e) { // HTT: Microservice.stop() failure
mid-teardown is not reproducible in the happy-path test boot.
+ throw new ExtensionContextException("@MicroserviceTest
failed to stop the microservice.", e);
+ }
+ }
+
+ private static State readState(ExtensionContext context) {
+ for (var c = context; c != null; c =
c.getParent().orElse(null)) {
+ var s = (State) c.getStore(NAMESPACE).get(KEY_STATE);
+ if (s != null)
+ return s;
+ }
+ return null;
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceTest.java
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceTest.java
new file mode 100644
index 0000000000..90c5eab160
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/MicroserviceTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microservice.test;
+
+import static java.lang.annotation.ElementType.*;
+import static java.lang.annotation.RetentionPolicy.*;
+
+import java.lang.annotation.*;
+
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+/**
+ * Boots a whole {@link org.apache.juneau.microservice.Microservice
Microservice} (config + lifecycle +
+ * embedded Jetty server) for the annotated JUnit 5 test class — the
standalone-microservice analog of
+ * Spring's {@code @SpringBootTest}.
+ *
+ * <p>
+ * {@code @MicroserviceTest} is a single, server-agnostic meta-annotation: it
composes
+ * {@link ExtendWith @ExtendWith}({@link MicroserviceExtension}) plus a {@code
@Tag("microservice")} marker.
+ * The extension builds the microservice on an OS-assigned ephemeral port
before the test class runs, and
+ * {@link org.apache.juneau.microservice.Microservice#stop() stop()}s it
afterward (releasing the port and
+ * firing {@code @PreDestroy} hooks).
+ *
+ * <h5 class='topic'>Specifying the system under test</h5>
+ *
+ * <p>
+ * The SUT is declared <i>explicitly</i> (no classpath scanning), two
complementary ways:
+ * <ol>
+ * <li>{@link #configurations()} — one or more {@code
@Configuration} classes whose {@code @Bean Servlet}
+ * methods are auto-mounted by the microservice (the common case).
+ * <li>A {@code static} method on the test class returning a
+ * {@link org.apache.juneau.microservice.Microservice.Builder
Microservice.Builder} — for full control
+ * over the builder. Discovered by name {@value
MicroserviceExtension#BUILDER_SUPPLIER_METHOD} (override via
+ * {@link #builderMethod()}). Configurations from {@link
#configurations()} are appended to whatever the
+ * supplier returns.
+ * </ol>
+ *
+ * <p>
+ * In both cases the extension additionally installs an {@link
EphemeralJettyServerConfig} (binding a Jetty
+ * {@code Server} to port 0) and {@code JettyConfiguration}, so tests only
contribute their resources.
+ *
+ * <h5 class='topic'>Mock-bean injection</h5>
+ *
+ * <p>
+ * Declare collaborators to substitute with the existing {@link
org.apache.juneau.junit5.TestBean @TestBean}
+ * (the {@code @MockBean} analog) — no parallel injection annotation. By
default these are installed via
+ * <b>Mode INJECT</b> (through {@code
Microservice.Builder.overridingBeanStore(...)}) <i>before</i> boot, so the
+ * service sees them from startup. {@code @TestBean(mode = Mode.OVERLAY)}
pushes/pops against the already-booted
+ * instance instead.
+ *
+ * <h5 class='topic'>What gets injected into tests</h5>
+ *
+ * <p>
+ * Test methods (and lifecycle methods) may declare parameters resolved by the
extension:
+ * {@link org.apache.juneau.rest.client.RestClient RestClient} (bound to the
booted server's root URL — the
+ * primary convenience), {@link org.apache.juneau.microservice.Microservice
Microservice}, the
+ * {@link org.apache.juneau.commons.inject.WritableBeanStore
WritableBeanStore}, and the bound port (as {@code int}
+ * / {@code Integer}). The same {@code TestBeanStore} parameter the underlying
+ * {@link org.apache.juneau.junit5.JuneauBeanStoreExtension} resolves is
available too.
+ *
+ * <h5 class='topic'>When to use this vs. {@code MockRestClient}</h5>
+ *
+ * <p>
+ * Use {@code @MicroserviceTest} for a genuine full-microservice integration
test — real server, real
+ * connectors, real lifecycle, over HTTP. For an in-JVM test of a single
{@code @Rest} resource (no server, no
+ * sockets), use {@code MockRestClient} directly; the two paths are
intentionally distinct.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <ja>@MicroserviceTest</ja>(configurations=MyServerConfig.<jk>class</jk>)
+ * <jk>class</jk> MyServiceTest {
+ *
+ * <ja>@Configuration</ja>
+ * <jk>static class</jk> MyServerConfig {
+ * <ja>@Bean</ja> Servlet myService() { <jk>return
new</jk> MyRestService(); }
+ * }
+ *
+ * <ja>@TestBean</ja>
+ * MyExternalApi <jv>mockApi</jv> =
Mockito.<jsm>mock</jsm>(MyExternalApi.<jk>class</jk>);
+ *
+ * <ja>@Test</ja>
+ * <jk>void</jk> aTest(RestClient <jv>client</jv>) {
+ *
<jv>client</jv>.get(<js>"/widgets/1"</js>).run().assertStatus().is(200);
+ * }
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link MicroserviceExtension}
+ * <li class='jc'>{@link org.apache.juneau.junit5.TestBean}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@Documented
+@Retention(RUNTIME)
+@Target(TYPE)
+@ExtendWith(MicroserviceExtension.class)
+@Tag("microservice")
+public @interface MicroserviceTest {
+
+ /**
+ * The {@code @Configuration} classes whose {@code @Bean Servlet}
methods the microservice auto-mounts.
+ *
+ * <p>
+ * Appended after any builder produced by a {@link #builderMethod()}
supplier, and before the framework's
+ * {@link EphemeralJettyServerConfig} + {@code JettyConfiguration}.
+ *
+ * @return The configuration classes. Empty by default (rely on a
{@link #builderMethod()} supplier).
+ */
+ Class<?>[] configurations() default {};
+
+ /**
+ * Name of an optional {@code static} method on the test class
returning a
+ * {@link org.apache.juneau.microservice.Microservice.Builder
Microservice.Builder} to seed the boot.
+ *
+ * <p>
+ * The method must be {@code static}, take no arguments, and return a
{@code Microservice.Builder}. When absent,
+ * a fresh {@code Microservice.create()} builder is used. {@link
#configurations()} are appended either way.
+ *
+ * @return The supplier method name. Defaults to {@value
MicroserviceExtension#BUILDER_SUPPLIER_METHOD}.
+ */
+ String builderMethod() default
MicroserviceExtension.BUILDER_SUPPLIER_METHOD;
+}
diff --git
a/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/package-info.java
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/package-info.java
new file mode 100644
index 0000000000..f0ce50e6b1
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/main/java/org/apache/juneau/microservice/test/package-info.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * JUnit 5 whole-microservice integration-test support — the {@code
@SpringBootTest} analog for the
+ * standalone Juneau microservice.
+ *
+ * <p>
+ * {@link org.apache.juneau.microservice.test.MicroserviceTest
@MicroserviceTest} boots a whole
+ * {@link org.apache.juneau.microservice.Microservice Microservice} (config +
lifecycle + embedded Jetty on an
+ * ephemeral port) for a test class, composing the existing {@code
juneau-junit5}
+ * {@link org.apache.juneau.junit5.TestBean @TestBean} mock-bean substrate for
collaborator substitution and
+ * resolving a {@link org.apache.juneau.rest.client.RestClient RestClient}
bound to the booted server for tests.
+ *
+ * <p>
+ * Use it for genuine full-microservice integration tests (real server,
connectors, lifecycle, over HTTP). For an
+ * in-JVM single-{@code @Rest}-resource test, use {@code MockRestClient}
directly.
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.microservice.test;
diff --git
a/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTestBuilderSupplier_Test.java
b/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTestBuilderSupplier_Test.java
new file mode 100644
index 0000000000..f556d4200d
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTestBuilderSupplier_Test.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microservice.test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.servlet.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies the {@code static Microservice.Builder} supplier path of {@link
MicroserviceTest @MicroserviceTest}
+ * (SUT specified via a builder method rather than {@code configurations=}),
and that a second
+ * {@code @MicroserviceTest} class boots + tears down cleanly in the same JVM
(isolation from
+ * {@link MicroserviceTest_Test}).
+ */
+@MicroserviceTest
+class MicroserviceTestBuilderSupplier_Test extends TestBase {
+
+ @Rest(paths = "/*")
+ public static class PingResource extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet("/ping")
+ public String ping() {
+ return "pong";
+ }
+ }
+
+ @Configuration
+ public static class PingConfig {
+ @Bean public Servlet pingResource() { return new
PingResource(); }
+ }
+
+ /** Builder-supplier discovered by the default name {@code
microserviceBuilder} (Q1 SUT-spec option b). */
+ static Microservice.Builder microserviceBuilder() {
+ return Microservice.create().configurations(PingConfig.class);
+ }
+
+ @Test void a01_builderSupplierBootsAndServes(RestClient client) throws
Exception {
+ var resp = client.get("/ping").run();
+ assertEquals(200, resp.getStatusCode());
+ assertEquals("pong", resp.getBodyAsString());
+ }
+
+ @Test void a02_freshInstancePerClass(Microservice ms) {
+ assertNotNull(ms);
+ assertNotNull(ms.getBeanStore());
+ }
+}
diff --git
a/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTest_Test.java
b/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTest_Test.java
new file mode 100644
index 0000000000..0d535acb34
--- /dev/null
+++
b/juneau-microservice/juneau-microservice-test/src/test/java/org/apache/juneau/microservice/test/MicroserviceTest_Test.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.microservice.test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import jakarta.servlet.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.junit5.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.microservice.jetty.*;
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.eclipse.jetty.server.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * End-to-end tests for {@link MicroserviceTest @MicroserviceTest} / {@link
MicroserviceExtension}: boots a real
+ * Jetty-backed microservice on an ephemeral port, hits it over HTTP via an
injected {@link RestClient}, and
+ * verifies {@link TestBean @TestBean} Mode-INJECT substitution is visible to
the booted service from startup.
+ */
+@MicroserviceTest(configurations = MicroserviceTest_Test.AppConfig.class)
+class MicroserviceTest_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Sample application under test: a @Rest resource whose response comes
from an injected collaborator.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ /** Collaborator the resource depends on — substituted in this test via
@TestBean (Mode INJECT). */
+ public interface Greeter {
+ String greet();
+ }
+
+ public static class ProductionGreeter implements Greeter {
+ @Override public String greet() { return "prod"; }
+ }
+
+ @Rest(paths = "/*")
+ public static class GreetingResource extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ private final Greeter greeter;
+
+ public GreetingResource(Greeter greeter) {
+ this.greeter = greeter;
+ }
+
+ @RestGet("/greeting")
+ public String greeting() {
+ return greeter.greet();
+ }
+ }
+
+ @Configuration
+ public static class AppConfig {
+ @Bean public Greeter greeter() { return new
ProductionGreeter(); }
+ @Bean public Servlet greetingResource(Greeter greeter) { return
new GreetingResource(greeter); }
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Mock-bean: a static @TestBean (CLASS scope) substitutes the Greeter
BEFORE boot (Mode INJECT default).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @TestBean(scope = Scope.CLASS)
+ static Greeter mockGreeter() {
+ return () -> "mocked";
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: lifecycle + parameter resolution.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_restClientInjected_hitsRealEndpoint(RestClient client)
throws Exception {
+ var resp = client.get("/greeting").run();
+ assertEquals(200, resp.getStatusCode());
+ // The @TestBean mock was injected before boot, so the
resource's collaborator is the mock.
+ assertEquals("mocked", resp.getBodyAsString());
+ }
+
+ @Test void a02_microserviceAndBeanStoreInjected(Microservice ms,
WritableBeanStore beanStore) {
+ assertNotNull(ms);
+ assertNotNull(beanStore);
+ // The substituted collaborator is the one resolvable from the
booted service's bean store.
+ assertEquals("mocked",
beanStore.getBean(Greeter.class).orElseThrow().greet());
+ }
+
+ @Test void a03_boundPortInjected(int port) {
+ assertTrue(port > 0, "Expected an OS-assigned ephemeral port,
got " + port);
+ }
+
+ @Test void a04_serverIsReallyListening(Microservice ms) {
+ var jsc =
ms.getBeanStore().getBean(JettyServerComponent.class).orElseThrow();
+ var listening = false;
+ for (var c : jsc.getServer().getConnectors())
+ if (c instanceof ServerConnector sc &&
sc.getLocalPort() > 0)
+ listening = true;
+ assertTrue(listening, "Server should have a bound connector
while the test class runs.");
+ }
+}
diff --git a/juneau-microservice/pom.xml b/juneau-microservice/pom.xml
index 635e4213c3..8f55c125cf 100644
--- a/juneau-microservice/pom.xml
+++ b/juneau-microservice/pom.xml
@@ -34,6 +34,7 @@
<module>juneau-microservice</module>
<module>juneau-microservice-jetty</module>
<module>juneau-microservice-tomcat</module>
+ <module>juneau-microservice-test</module>
</modules>
<build>
diff --git a/scripts/check-container-tags.py b/scripts/check-container-tags.py
index 0733b97485..9b66162dad 100644
--- a/scripts/check-container-tags.py
+++ b/scripts/check-container-tags.py
@@ -45,9 +45,11 @@ TAG_MARKERS = (
"@SpringbootTest",
"@JettyMicroserviceTest",
"@TomcatMicroserviceTest",
+ "@MicroserviceTest",
"@org.apache.juneau.testing.annotations.SpringbootTest",
"@org.apache.juneau.testing.annotations.JettyMicroserviceTest",
"@org.apache.juneau.testing.annotations.TomcatMicroserviceTest",
+ "@org.apache.juneau.microservice.test.MicroserviceTest",
)
@@ -93,7 +95,7 @@ def main() -> int:
for path in offenders:
rel = path.relative_to(repo_root)
print(f" - {rel}")
- print("\nAdd @SpringbootTest, @JettyMicroserviceTest, or
@TomcatMicroserviceTest to each class.")
+ print("\nAdd @MicroserviceTest, @SpringbootTest, @JettyMicroserviceTest,
or @TomcatMicroserviceTest to each class.")
return 1