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 static 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.CodeContribution; 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 // Check if provided and evaluate for most special type 152 if (!requested.accepts(TestResultType)) { 153 return Collections.emptyList(); 154 } 155 if (!requested.isFor(TestResultType)) { 156 @SuppressWarnings("unchecked") 157 var result = (Collection<T>) resources(of(TestResultType)).toList(); 158 return result; 159 } 160 161 // Collect the classpath. 162 var cpResources = Resources.of(CodeContributionsType) 163 .addAll(project().resources(of(CodeContributionType) 164 .using(Consume, Reveal, Expose, Supply))); 165 if (project() instanceof MergedTestProject) { 166 cpResources.addAll(project().parentProject().get() 167 .resources(of(CodeContributionType).using(Consume, 168 Reveal, Expose, Supply))); 169 } 170 logger.atFiner().log("Testing in %s with classpath %s", project(), 171 lazy(() -> cpResources.stream().map(e -> e.toPath().toString()) 172 .collect(Collectors.joining(File.pathSeparator)))); 173 174 // Run the tests 175 ClassLoader oldLoader = Thread.currentThread().getContextClassLoader(); 176 try (URLClassLoader testLoader = new URLClassLoader( 177 Stream.concat(project() 178 .resources(of(CodeContributionType).using(Consume, Reveal)), 179 cpResources.stream()).map(CodeContribution::toPath) 180 .map(Path::toUri).map(uri -> { 181 try { 182 return uri.toURL(); 183 } catch (MalformedURLException e) { 184 throw new IllegalArgumentException(e); 185 } 186 }).toArray(URL[]::new), 187 ClassLoader.getSystemClassLoader())) { 188 Thread.currentThread().setContextClassLoader(testLoader); 189 190 // Discover all tests from generator's output 191 var testClassTrees = project().providers(Consume, Reveal).filter( 192 p -> p instanceof Generator).resources(of(ClassTreeType)) 193 .map(ClassTree::root).collect(Collectors.toSet()); 194 LauncherDiscoveryRequest request 195 = LauncherDiscoveryRequestBuilder.request().selectors( 196 DiscoverySelectors.selectClasspathRoots(testClassTrees)) 197 .build(); 198 199 // Run the tests 200 var launcher = LauncherFactory.create(); 201 var summaryListener = new SummaryGeneratingListener(); 202 var testListener = new TestListener(); 203 launcher.registerTestExecutionListeners( 204 LoggingListener.forJavaUtilLogging(), summaryListener, 205 testListener); 206 logger.atInfo().log("Running tests in project %s", 207 project().name()); 208 if (syncObject != null) { 209 synchronized (syncObject) { 210 launcher.execute(request); 211 } 212 } else { 213 launcher.execute(request); 214 } 215 216 // Evaluate results 217 var summary = summaryListener.getSummary(); 218 var result = TestResult.of(project(), this, 219 buildName(testListener), summary.getTestsStartedCount(), 220 summary.getTestsFailedCount()); 221 if (summary.getTestsFailedCount() > 0 && !ignoreFailed) { 222 result.setFaulty(); 223 } 224 @SuppressWarnings("unchecked") 225 var asList = List.of((T) result); 226 return asList; 227 } catch (IOException e) { 228 logger.atWarning().withCause(e).log("Failed to close classloader"); 229 } finally { 230 Thread.currentThread().setContextClassLoader(oldLoader); 231 } 232 233 // Return result 234 return Collections.emptyList(); 235 } 236 237 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 238 private String buildName(TestListener testListener) { 239 StringBuilder asList = new StringBuilder(); 240 for (var testId : testListener.testIds()) { 241 if (!asList.isEmpty()) { 242 asList.append(", "); 243 } 244 if (asList.length() > 30) { 245 asList.append(" ..."); 246 break; 247 } 248 asList.append(testId.getDisplayName()); 249 } 250 return asList.toString(); 251 } 252 253 private void printExecutionResult(String testName, 254 TestExecutionResult result) { 255 context().error().format("Failed: %s\n", testName); 256 if (result.getThrowable().isEmpty()) { 257 return; 258 } 259 260 // Find INITIAL exception 261 Throwable thrown = result.getThrowable().get(); 262 while (thrown.getCause() != null) { 263 thrown = thrown.getCause(); 264 } 265 thrown.printStackTrace(context().error()); 266 } 267 268 /// A [TestExecutionListener] for JUnit. 269 /// 270 /// @see TestEvent 271 /// 272 @SuppressWarnings("PMD.TestClassWithoutTestCases") 273 private final class TestListener implements TestExecutionListener { 274 275 private final Set<TestIdentifier> tests = new HashSet<>(); 276 private TestPlan testPlan; 277 278 /// Return the test classes. 279 /// 280 /// @return the sets the 281 /// 282 private Set<TestIdentifier> testIds() { 283 return tests; 284 } 285 286 private String prettyTestName(TestIdentifier testId) { 287 return Stream.iterate(testId, Objects::nonNull, 288 id -> testPlan.getParent(id).orElse(null)) 289 .toList().reversed().stream().skip(1) 290 .map(TestIdentifier::getDisplayName) 291 .collect(Collectors.joining(" > ")); 292 } 293 294 @Override 295 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 296 public void testPlanExecutionStarted(TestPlan testPlan) { 297 this.testPlan = testPlan; 298 } 299 300 @Override 301 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 302 public void testPlanExecutionFinished(TestPlan testPlan) { 303 // Not tracked 304 } 305 306 @Override 307 public void dynamicTestRegistered(TestIdentifier testIdentifier) { 308 // Not tracked 309 } 310 311 @Override 312 public void executionSkipped(TestIdentifier testIdentifier, 313 String reason) { 314 // Not tracked 315 } 316 317 @Override 318 public void executionStarted(TestIdentifier testIdentifier) { 319 if (testIdentifier.getSource().isPresent() 320 && testIdentifier.getSource().get() instanceof ClassSource) { 321 tests.add(testIdentifier); 322 } 323 context().statusLine().update(JUnitTestRunner.this 324 + " running: " + prettyTestName(testIdentifier)); 325 } 326 327 @Override 328 public void executionFinished(TestIdentifier testIdentifier, 329 TestExecutionResult testExecutionResult) { 330 if (testExecutionResult.getStatus() == Status.SUCCESSFUL) { 331 if (testIdentifier.getType() != Type.TEST) { 332 return; 333 } 334 logger.atFine().log("Succeeded: %s", 335 lazy(() -> prettyTestName(testIdentifier))); 336 return; 337 } 338 if (testExecutionResult.getThrowable().isEmpty()) { 339 logger.atWarning().log("Failed: %s", 340 lazy(() -> prettyTestName(testIdentifier))); 341 printExecutionResult(prettyTestName(testIdentifier), 342 testExecutionResult); 343 return; 344 } 345 logger.atWarning() 346 .withCause(testExecutionResult.getThrowable().get()) 347 .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier))); 348 printExecutionResult(prettyTestName(testIdentifier), 349 testExecutionResult); 350 } 351 352 @Override 353 public void reportingEntryPublished(TestIdentifier testIdentifier, 354 ReportEntry entry) { 355 // Not tracked 356 } 357 358 @Override 359 public void fileEntryPublished(TestIdentifier testIdentifier, 360 FileEntry file) { 361 // Not tracked 362 } 363 } 364}