001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.junit; 020 021import com.google.common.flogger.FluentLogger; 022import static com.google.common.flogger.LazyArgs.*; 023import java.io.File; 024import java.io.IOException; 025import java.net.MalformedURLException; 026import java.net.URL; 027import java.net.URLClassLoader; 028import java.nio.file.Path; 029import java.util.Collection; 030import java.util.Collections; 031import java.util.HashSet; 032import java.util.List; 033import java.util.Objects; 034import java.util.Set; 035import java.util.stream.Collectors; 036import java.util.stream.Stream; 037import org.jdrupes.builder.api.Generator; 038import org.jdrupes.builder.api.Intent; 039import static org.jdrupes.builder.api.Intent.*; 040import org.jdrupes.builder.api.MergedTestProject; 041import org.jdrupes.builder.api.Project; 042import org.jdrupes.builder.api.Resource; 043import org.jdrupes.builder.api.ResourceRequest; 044import org.jdrupes.builder.api.ResourceType; 045import org.jdrupes.builder.api.Resources; 046import org.jdrupes.builder.api.TestResult; 047import org.jdrupes.builder.core.AbstractGenerator; 048import org.jdrupes.builder.java.ClassTree; 049import org.jdrupes.builder.java.ClasspathElement; 050import static org.jdrupes.builder.java.JavaTypes.*; 051import org.junit.platform.engine.TestDescriptor.Type; 052import org.junit.platform.engine.TestExecutionResult; 053import org.junit.platform.engine.TestExecutionResult.Status; 054import org.junit.platform.engine.discovery.DiscoverySelectors; 055import org.junit.platform.engine.reporting.FileEntry; 056import org.junit.platform.engine.reporting.ReportEntry; 057import org.junit.platform.engine.support.descriptor.ClassSource; 058import org.junit.platform.launcher.LauncherDiscoveryRequest; 059import org.junit.platform.launcher.TestExecutionListener; 060import org.junit.platform.launcher.TestIdentifier; 061import org.junit.platform.launcher.TestPlan; 062import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; 063import org.junit.platform.launcher.core.LauncherFactory; 064import org.junit.platform.launcher.listeners.LoggingListener; 065import org.junit.platform.launcher.listeners.SummaryGeneratingListener; 066 067/// A [Generator] for [TestResult]s using the JUnit platform. The runner 068/// assumes that it is configured as [Generator] for a test project. 069/// The class path for running the tests is build as follows: 070/// 071/// 1. Request compilation classpath resources from the test project's 072/// dependencies with [Intent#Consume], [Intent#Expose], 073/// and [Intent#Supply]. This makes the resources available that 074/// are used for compiling test classes as well as the compiled 075/// test classes. 076/// 077/// 2. If the project implements [MergedTestProject], get the 078/// [Project#parentProject()], request compilation class path 079/// resources from its dependencies with [Intent#Consume], 080/// [Intent#Expose], and [Intent#Supply] and add them to the 081/// class path. This makes the resources available that are used 082/// for compiling the classes under test as well as the classes 083/// under test. Note that this is partially redundant, because 084/// test projects most likely have a dependency with [Intent#Consume] 085/// on the project under test anyway in order to compile the test 086/// classes. This dependency does not, however, provide all resources 087/// that are required to test the project under test. 088/// 089/// The runner then requests all resources of type [ClassTree] from 090/// the test projects's [Generator]'s and passes them to JUnit's 091/// test class detector. 092/// 093/// Libraries for compiling the tests and a test engine of your choice 094/// must be provided explicitly to the runner's project as dependencies, 095/// e.g. as: 096/// ``` 097/// project.dependency(Consume, new MvnRepoLookup() 098/// .bom("org.junit:junit-bom:5.12.2") 099/// .resolve("org.junit.jupiter:junit-jupiter-api") 100/// .resolve(Scope.Runtime, 101/// "org.junit.jupiter:junit-jupiter-engine")); 102/// ``` 103/// 104/// In order to track the execution of the each test, you can enable 105/// level fine logging for this class. Level finer will also 106/// provide information about the class paths. 107/// 108public class JUnitTestRunner extends AbstractGenerator { 109 110 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 111 private boolean ignoreFailed; 112 private Object syncObject; 113 114 /// Initializes a new test runner. 115 /// 116 /// @param project the project 117 /// 118 public JUnitTestRunner(Project project) { 119 super(project); 120 } 121 122 /// Ignore failed tests. If invoked, the test runner does not set the 123 /// faulty flag of the test results if a test has failed. 124 /// 125 /// @return the junit test runner 126 /// 127 public JUnitTestRunner ignoreFailed() { 128 this.ignoreFailed = true; 129 return this; 130 } 131 132 /// By default, [JUnitTestRunner]s run independently of each other. 133 /// Because they run in the VM, this can cause concurrency issues if 134 /// components in different test projects share static resources. 135 /// 136 /// By invoking this method, [JUnitTestRunner]s with the same 137 /// [syncObject] are synchronized, i.e. run in sequence. 138 /// 139 /// @param syncObject the sync object 140 /// @return the j unit test runner 141 /// 142 public JUnitTestRunner syncOn(Object syncObject) { 143 this.syncObject = syncObject; 144 return this; 145 } 146 147 @Override 148 @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.NcssCount" }) 149 protected <T extends Resource> Collection<T> 150 doProvide(ResourceRequest<T> requested) { 151 if (!requested.accepts(new ResourceType<TestResult>() {})) { 152 return Collections.emptyList(); 153 } 154 155 // Collect the classpath. 156 var cpResources = Resources.of(ClasspathType) 157 .addAll(project().resources(of(ClasspathElementType) 158 .using(Consume, Reveal, Expose, Supply))); 159 if (project() instanceof MergedTestProject) { 160 cpResources.addAll(project().parentProject().get() 161 .resources(of(ClasspathElementType).using(Consume, 162 Reveal, Expose, Supply))); 163 } 164 logger.atFiner().log("Testing in %s with classpath %s", project(), 165 lazy(() -> cpResources.stream().map(e -> e.toPath().toString()) 166 .collect(Collectors.joining(File.pathSeparator)))); 167 168 // Run the tests 169 ClassLoader oldLoader = Thread.currentThread().getContextClassLoader(); 170 try (URLClassLoader testLoader = new URLClassLoader( 171 Stream.concat(project() 172 .resources(of(ClasspathElementType).using(Consume, Reveal)), 173 cpResources.stream()).map(ClasspathElement::toPath) 174 .map(Path::toUri).map(uri -> { 175 try { 176 return uri.toURL(); 177 } catch (MalformedURLException e) { 178 throw new IllegalArgumentException(e); 179 } 180 }).toArray(URL[]::new), 181 ClassLoader.getSystemClassLoader())) { 182 Thread.currentThread().setContextClassLoader(testLoader); 183 184 // Discover all tests from generator's output 185 var testClassTrees = project().providers(Consume, Reveal).filter( 186 p -> p instanceof Generator).resources(of(ClassTreeType)) 187 .map(ClassTree::root).collect(Collectors.toSet()); 188 LauncherDiscoveryRequest request 189 = LauncherDiscoveryRequestBuilder.request().selectors( 190 DiscoverySelectors.selectClasspathRoots(testClassTrees)) 191 .build(); 192 193 // Run the tests 194 var launcher = LauncherFactory.create(); 195 var summaryListener = new SummaryGeneratingListener(); 196 var testListener = new TestListener(); 197 launcher.registerTestExecutionListeners( 198 LoggingListener.forJavaUtilLogging(), summaryListener, 199 testListener); 200 logger.atInfo().log("Running tests in project %s", 201 project().name()); 202 if (syncObject != null) { 203 synchronized (syncObject) { 204 launcher.execute(request); 205 } 206 } else { 207 launcher.execute(request); 208 } 209 210 // Evaluate results 211 var summary = summaryListener.getSummary(); 212 var result = TestResult.of(project(), this, 213 buildName(testListener), summary.getTestsStartedCount(), 214 summary.getTestsFailedCount()); 215 if (summary.getTestsFailedCount() > 0 && !ignoreFailed) { 216 result.setFaulty(); 217 } 218 @SuppressWarnings("unchecked") 219 var asList = List.of((T) result); 220 return asList; 221 } catch (IOException e) { 222 logger.atWarning().withCause(e).log("Failed to close classloader"); 223 } finally { 224 Thread.currentThread().setContextClassLoader(oldLoader); 225 } 226 227 // Return result 228 return Collections.emptyList(); 229 } 230 231 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 232 private String buildName(TestListener testListener) { 233 StringBuilder asList = new StringBuilder(); 234 for (var testId : testListener.testIds()) { 235 if (!asList.isEmpty()) { 236 asList.append(", "); 237 } 238 if (asList.length() > 30) { 239 asList.append(" ..."); 240 break; 241 } 242 asList.append(testId.getDisplayName()); 243 } 244 return asList.toString(); 245 } 246 247 private void printExecutionResult(String testName, 248 TestExecutionResult result) { 249 context().error().format("Failed: %s\n", testName); 250 if (result.getThrowable().isEmpty()) { 251 return; 252 } 253 254 // Find INITIAL exception 255 Throwable thrown = result.getThrowable().get(); 256 while (thrown.getCause() != null) { 257 thrown = thrown.getCause(); 258 } 259 thrown.printStackTrace(context().error()); 260 } 261 262 /// A [TestExecutionListener] for JUnit. 263 /// 264 /// @see TestEvent 265 /// 266 @SuppressWarnings("PMD.TestClassWithoutTestCases") 267 private final class TestListener implements TestExecutionListener { 268 269 private final Set<TestIdentifier> tests = new HashSet<>(); 270 private TestPlan testPlan; 271 272 /// Return the test classes. 273 /// 274 /// @return the sets the 275 /// 276 private Set<TestIdentifier> testIds() { 277 return tests; 278 } 279 280 private String prettyTestName(TestIdentifier testId) { 281 return Stream.iterate(testId, Objects::nonNull, 282 id -> testPlan.getParent(id).orElse(null)) 283 .toList().reversed().stream().skip(1) 284 .map(TestIdentifier::getDisplayName) 285 .collect(Collectors.joining(" > ")); 286 } 287 288 @Override 289 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 290 public void testPlanExecutionStarted(TestPlan testPlan) { 291 this.testPlan = testPlan; 292 } 293 294 @Override 295 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 296 public void testPlanExecutionFinished(TestPlan testPlan) { 297 // Not tracked 298 } 299 300 @Override 301 public void dynamicTestRegistered(TestIdentifier testIdentifier) { 302 // Not tracked 303 } 304 305 @Override 306 public void executionSkipped(TestIdentifier testIdentifier, 307 String reason) { 308 // Not tracked 309 } 310 311 @Override 312 public void executionStarted(TestIdentifier testIdentifier) { 313 if (testIdentifier.getSource().isPresent() 314 && testIdentifier.getSource().get() instanceof ClassSource) { 315 tests.add(testIdentifier); 316 } 317 context().statusLine().update(JUnitTestRunner.this 318 + " running: " + prettyTestName(testIdentifier)); 319 } 320 321 @Override 322 public void executionFinished(TestIdentifier testIdentifier, 323 TestExecutionResult testExecutionResult) { 324 if (testExecutionResult.getStatus() == Status.SUCCESSFUL) { 325 if (testIdentifier.getType() != Type.TEST) { 326 return; 327 } 328 logger.atFine().log("Succeeded: %s", 329 lazy(() -> prettyTestName(testIdentifier))); 330 return; 331 } 332 if (testExecutionResult.getThrowable().isEmpty()) { 333 logger.atWarning().log("Failed: %s", 334 lazy(() -> prettyTestName(testIdentifier))); 335 printExecutionResult(prettyTestName(testIdentifier), 336 testExecutionResult); 337 return; 338 } 339 logger.atWarning() 340 .withCause(testExecutionResult.getThrowable().get()) 341 .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier))); 342 printExecutionResult(prettyTestName(testIdentifier), 343 testExecutionResult); 344 } 345 346 @Override 347 public void reportingEntryPublished(TestIdentifier testIdentifier, 348 ReportEntry entry) { 349 // Not tracked 350 } 351 352 @Override 353 public void fileEntryPublished(TestIdentifier testIdentifier, 354 FileEntry file) { 355 // Not tracked 356 } 357 } 358}