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