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} &mdash; 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 &mdash; 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 &mdash; 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()} &mdash; 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} &mdash; 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) &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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
 
 


Reply via email to