001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.microservice.test; 018 019import java.lang.reflect.*; 020import java.net.*; 021 022import org.apache.juneau.commons.inject.*; 023import org.apache.juneau.microservice.*; 024import org.apache.juneau.microservice.jetty.*; 025import org.apache.juneau.rest.client.*; 026import org.apache.juneau.bct.junit.*; 027import org.eclipse.jetty.server.*; 028import org.junit.jupiter.api.extension.*; 029 030/** 031 * JUnit 5 extension behind {@link MicroserviceTest @MicroserviceTest}: boots a whole 032 * {@link Microservice} (config + lifecycle + embedded Jetty on an ephemeral port) for the test class, with 033 * {@link TestBean @TestBean} mock-bean injection and convenience parameter resolution. 034 * 035 * <h5 class='topic'>Lifecycle (per class)</h5> 036 * 037 * <p> 038 * The microservice is built + {@link Microservice#start() started} once in {@code beforeAll} and 039 * {@link Microservice#stop() stopped} in {@code afterAll}. A fresh instance is booted per class and stopped 040 * cleanly — never reused, since {@code Microservice} restart is unsupported (the {@code stopped} flag is 041 * one-way, the bean store is closed, and each {@code start()} adds a JVM shutdown hook). The bound port is read 042 * from the live {@link ServerConnector} (never hard-coded), mirroring the established fixture pattern. 043 * 044 * <h5 class='topic'>Mock-bean injection</h5> 045 * 046 * <p> 047 * {@link TestBean @TestBean} declarations are discovered via 048 * {@link JuneauBeanStoreExtension#discoverOverrides(Object)}. <b>Mode INJECT</b> (the default) installs the 049 * overrides via {@code Microservice.Builder.overridingBeanStore(...)} <i>before</i> boot, so the service reads 050 * them from startup. <b>Mode OVERLAY</b> pushes the overrides onto the booted instance's bean store for the 051 * class duration and pops them in teardown. 052 * 053 * <h5 class='topic'>Parameter resolution</h5> 054 * 055 * <p> 056 * Resolves {@link RestClient} (bound to the booted root URL), {@link Microservice}, {@link WritableBeanStore}, 057 * and the bound port ({@code int} / {@code Integer}) on test + lifecycle method parameters. 058 * 059 * @since 10.0.0 060 */ 061@SuppressWarnings({ 062 "java:S3011", // setAccessible(true) is needed to call the test class's (possibly private) builder method and no-arg constructor. 063 "resource" // The RestClient and Microservice are created in beforeAll(), kept in the per-class State, and closed/stopped in afterAll(). 064}) 065public class MicroserviceExtension implements BeforeAllCallback, AfterAllCallback, ParameterResolver { 066 067 /** Default name of the optional {@code static Microservice.Builder} supplier method on the test class. */ 068 public static final String BUILDER_SUPPLIER_METHOD = "microserviceBuilder"; 069 070 private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(MicroserviceExtension.class); 071 private static final String KEY_STATE = "state"; 072 073 /** Per-class booted state. */ 074 private static final class State { 075 Microservice microservice; 076 RestClient client; 077 URI rootUrl; 078 WritableBeanStore beanStore; 079 Snapshot overlaySnapshot; // non-null when Mode OVERLAY was pushed 080 } 081 082 @Override /* BeforeAllCallback */ 083 public void beforeAll(ExtensionContext context) throws Exception { 084 var testClass = context.getRequiredTestClass(); 085 var ann = findAnnotation(testClass); 086 087 // Discover @TestBean overrides from the test class (static members participate at class-boot time). 088 var instance = instantiateForDiscovery(testClass); 089 var overrides = JuneauBeanStoreExtension.discoverOverrides(instance); 090 091 var builder = resolveBuilder(testClass, ann); 092 093 // The user's configurations come first (so @Bean Servlet methods are visible), then the ephemeral Jetty 094 // server (port 0), then JettyConfiguration wires the lifecycle. BeanStore returns the first registered 095 // match, so a user-supplied @Bean Server still wins. 096 builder.configurations(ann.configurations()); 097 builder.configurations(EphemeralJettyServerConfig.class, JettyConfiguration.class); 098 099 var injecting = ! overrides.isEmpty() && overrides.mode() == Mode.INJECT; 100 if (injecting) 101 builder.overridingBeanStore(overrides.store()); 102 103 var state = new State(); 104 state.microservice = builder.build(); 105 state.microservice.start(); 106 state.beanStore = state.microservice.getBeanStore(); 107 state.rootUrl = resolveRootUrl(state.microservice); 108 state.client = RestClient.builder().rootUrl(state.rootUrl.toString()).build(); 109 110 // Mode OVERLAY: push the overrides onto the booted instance's bean store for the class duration. 111 if (! overrides.isEmpty() && overrides.mode() == Mode.OVERLAY) 112 state.overlaySnapshot = state.beanStore.pushOverlay(overrides.store()); 113 114 context.getStore(NAMESPACE).put(KEY_STATE, state); 115 } 116 117 @Override /* AfterAllCallback */ 118 public void afterAll(ExtensionContext context) { 119 var state = (State) context.getStore(NAMESPACE).remove(KEY_STATE); 120 if (state == null) 121 return; 122 try { 123 if (state.overlaySnapshot != null) 124 state.beanStore.popOverlay(state.overlaySnapshot); 125 } finally { 126 try { 127 if (state.client != null) 128 state.client.close(); 129 } catch (Exception e) { // HTT: RestClient.close() failure is not reproducible against the in-process JDK transport. 130 // Best-effort close; never mask teardown. 131 } finally { 132 if (state.microservice != null) 133 safeStop(state.microservice); 134 } 135 } 136 } 137 138 //----------------------------------------------------------------------------------------------------------------- 139 // ParameterResolver 140 //----------------------------------------------------------------------------------------------------------------- 141 142 @Override /* ParameterResolver */ 143 public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) { 144 var t = pc.getParameter().getType(); 145 return t == RestClient.class || t == Microservice.class || t == WritableBeanStore.class 146 || t == int.class || t == Integer.class; 147 } 148 149 @Override /* ParameterResolver */ 150 public Object resolveParameter(ParameterContext pc, ExtensionContext ec) { 151 var state = readState(ec); 152 if (state == null) 153 throw new ParameterResolutionException("@MicroserviceTest has no booted microservice to resolve from."); 154 var t = pc.getParameter().getType(); 155 if (t == RestClient.class) 156 return state.client; 157 if (t == Microservice.class) 158 return state.microservice; 159 if (t == WritableBeanStore.class) 160 return state.beanStore; 161 if (t == int.class || t == Integer.class) 162 return state.rootUrl.getPort(); 163 throw new ParameterResolutionException("Unsupported @MicroserviceTest parameter type: " + t.getName()); 164 } 165 166 //----------------------------------------------------------------------------------------------------------------- 167 // Helpers 168 //----------------------------------------------------------------------------------------------------------------- 169 170 private static MicroserviceTest findAnnotation(Class<?> testClass) { 171 for (var c = testClass; c != null; c = c.getSuperclass()) { 172 var a = c.getAnnotation(MicroserviceTest.class); 173 if (a != null) 174 return a; 175 } 176 throw new ExtensionContextException("@MicroserviceTest annotation not found on " + testClass.getName()); 177 } 178 179 private static Microservice.Builder resolveBuilder(Class<?> testClass, MicroserviceTest ann) { 180 var m = findBuilderSupplier(testClass, ann.builderMethod()); 181 if (m == null) 182 return Microservice.create(); 183 try { 184 m.setAccessible(true); 185 var result = m.invoke(null); 186 if (! (result instanceof Microservice.Builder b)) 187 throw new ExtensionContextException( 188 "@MicroserviceTest builder method '" + ann.builderMethod() + "' on " + testClass.getName() 189 + " must return a Microservice.Builder."); 190 return b; 191 } catch (ReflectiveOperationException e) { 192 throw new ExtensionContextException( 193 "Failed to invoke @MicroserviceTest builder method '" + ann.builderMethod() + "' on " + testClass.getName(), e); 194 } 195 } 196 197 private static Method findBuilderSupplier(Class<?> testClass, String name) { 198 for (var c = testClass; c != null; c = c.getSuperclass()) { 199 try { 200 var m = c.getDeclaredMethod(name); 201 if (Modifier.isStatic(m.getModifiers())) 202 return m; 203 throw new ExtensionContextException( 204 "@MicroserviceTest builder method '" + name + "' on " + c.getName() + " must be static."); 205 } catch (NoSuchMethodException e) { 206 // Try the superclass. 207 } 208 } 209 return null; 210 } 211 212 /** 213 * Instantiates the test class via its no-arg constructor purely to drive {@code @TestBean} discovery (which 214 * reads instance + static members). Falls back to a hierarchy-static-only scan if no usable no-arg ctor exists. 215 */ 216 private static Object instantiateForDiscovery(Class<?> testClass) { 217 try { 218 var ctor = testClass.getDeclaredConstructor(); 219 ctor.setAccessible(true); 220 return ctor.newInstance(); 221 } catch (ReflectiveOperationException e) { 222 // No accessible no-arg ctor (e.g. @TestInstance(PER_CLASS) with constructor injection). Static-only 223 // @TestBean discovery still works via a throwaway minimal instance of Object's identity is impossible, 224 // so re-raise with guidance. 225 throw new ExtensionContextException( 226 "@MicroserviceTest requires a no-arg constructor on " + testClass.getName() 227 + " to discover @TestBean overrides before boot.", e); 228 } 229 } 230 231 private static URI resolveRootUrl(Microservice microservice) { 232 var component = microservice.getBeanStore().getBean(JettyServerComponent.class).orElseThrow( 233 () -> new ExtensionContextException("@MicroserviceTest could not find a JettyServerComponent after start().")); 234 var server = component.getServer(); 235 var localPort = -1; 236 for (var c : server.getConnectors()) { 237 if (c instanceof ServerConnector sc) { 238 localPort = sc.getLocalPort(); 239 break; 240 } 241 } 242 if (localPort <= 0) 243 throw new ExtensionContextException("@MicroserviceTest could not determine the bound ServerConnector port after start()."); 244 return URI.create("http://localhost:" + localPort); 245 } 246 247 private static void safeStop(Microservice microservice) { 248 try { 249 microservice.stop(); 250 } catch (Exception e) { // HTT: Microservice.stop() failure mid-teardown is not reproducible in the happy-path test boot. 251 throw new ExtensionContextException("@MicroserviceTest failed to stop the microservice.", e); 252 } 253 } 254 255 private static State readState(ExtensionContext context) { 256 for (var c = context; c != null; c = c.getParent().orElse(null)) { 257 var s = (State) c.getStore(NAMESPACE).get(KEY_STATE); 258 if (s != null) 259 return s; 260 } 261 return null; 262 } 263}