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 static org.jdrupes.builder.api.ResourceType.*; 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 110 /// Initializes a new test runner. 111 /// 112 /// @param project the project 113 /// 114 public JUnitTestRunner(Project project) { 115 super(project); 116 } 117 118 /// Ignore failed tests. If invoked, the test runner does not set the 119 /// faulty flag of the test results if a test has failed. 120 /// 121 /// @return the junit test runner 122 /// 123 public JUnitTestRunner ignoreFailed() { 124 this.ignoreFailed = true; 125 return this; 126 } 127 128 @Override 129 protected <T extends Resource> Stream<T> 130 doProvide(ResourceRequest<T> requested) { 131 if (!requested.accepts(new ResourceType<TestResult>() {})) { 132 return Stream.empty(); 133 } 134 135 // Collect the classpath. 136 var cpResources = newResource(ClasspathType) 137 .addAll(project().resources(of(ClasspathElementType) 138 .using(Consume, Reveal, Expose, Supply))); 139 if (project() instanceof MergedTestProject) { 140 cpResources.addAll(project().parentProject().get() 141 .resources(of(ClasspathElement.class).using(Consume, 142 Reveal, Expose, Supply))); 143 } 144 logger.atFiner().log("Testing in %s with classpath %s", project(), 145 lazy(() -> cpResources.stream().map(e -> e.toPath().toString()) 146 .collect(Collectors.joining(File.pathSeparator)))); 147 148 // Run the tests 149 ClassLoader oldLoader = Thread.currentThread().getContextClassLoader(); 150 try (URLClassLoader testLoader = new URLClassLoader( 151 Stream.concat(project() 152 .resources(of(ClasspathElementType).using(Consume, Reveal)), 153 cpResources.stream()).map(ClasspathElement::toPath) 154 .map(Path::toUri).map(uri -> { 155 try { 156 return uri.toURL(); 157 } catch (MalformedURLException e) { 158 throw new IllegalArgumentException(e); 159 } 160 }).toArray(URL[]::new), 161 ClassLoader.getSystemClassLoader())) { 162 Thread.currentThread().setContextClassLoader(testLoader); 163 164 // Discover all tests from generator's output 165 var testClassTrees = project().providers(Consume, Reveal).filter( 166 p -> p instanceof Generator).resources(of(ClassTreeType)) 167 .map(ClassTree::root).collect(Collectors.toSet()); 168 LauncherDiscoveryRequest request 169 = LauncherDiscoveryRequestBuilder.request().selectors( 170 DiscoverySelectors.selectClasspathRoots(testClassTrees)) 171 .build(); 172 173 // Run the tests 174 var launcher = LauncherFactory.create(); 175 var summaryListener = new SummaryGeneratingListener(); 176 var testListener = new TestListener(); 177 launcher.registerTestExecutionListeners( 178 LoggingListener.forJavaUtilLogging(), summaryListener, 179 testListener); 180 logger.atInfo().log("Running tests in project %s", 181 project().name()); 182 launcher.execute(request); 183 184 // Evaluate results 185 var summary = summaryListener.getSummary(); 186 var result = project().newResource(TestResultType, 187 this, buildName(testListener), summary.getTestsStartedCount(), 188 summary.getTestsFailedCount()); 189 if (summary.getTestsFailedCount() > 0 && !ignoreFailed) { 190 result.setFaulty(); 191 } 192 @SuppressWarnings("unchecked") 193 var asStream = Stream.of((T) result); 194 return asStream; 195 } catch (IOException e) { 196 logger.atWarning().withCause(e).log("Failed to close classloader"); 197 } finally { 198 Thread.currentThread().setContextClassLoader(oldLoader); 199 } 200 201 // Return result 202 return Stream.empty(); 203 } 204 205 @SuppressWarnings("PMD.AvoidLiteralsInIfCondition") 206 private String buildName(TestListener testListener) { 207 StringBuilder asList = new StringBuilder(); 208 for (var testId : testListener.testIds()) { 209 if (!asList.isEmpty()) { 210 asList.append(", "); 211 } 212 if (asList.length() > 30) { 213 asList.append(" ..."); 214 break; 215 } 216 asList.append(testId.getDisplayName()); 217 } 218 return asList.toString(); 219 } 220 221 /// A [TestExecutionListener] for JUnit. 222 /// 223 /// @see TestEvent 224 /// 225 @SuppressWarnings("PMD.TestClassWithoutTestCases") 226 private final class TestListener implements TestExecutionListener { 227 228 private final Set<TestIdentifier> tests = new HashSet<>(); 229 private TestPlan testPlan; 230 231 /// Return the test classes. 232 /// 233 /// @return the sets the 234 /// 235 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 236 public Set<TestIdentifier> testIds() { 237 return tests; 238 } 239 240 private String prettyTestName(TestIdentifier testId) { 241 return Stream.iterate(testId, Objects::nonNull, 242 id -> testPlan.getParent(id).orElse(null)) 243 .toList().reversed().stream().skip(1) 244 .map(TestIdentifier::getDisplayName) 245 .collect(Collectors.joining(" > ")); 246 } 247 248 @Override 249 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 250 public void testPlanExecutionStarted(TestPlan testPlan) { 251 this.testPlan = testPlan; 252 } 253 254 @Override 255 @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation") 256 public void testPlanExecutionFinished(TestPlan testPlan) { 257 // Not tracked 258 } 259 260 @Override 261 public void dynamicTestRegistered(TestIdentifier testIdentifier) { 262 // Not tracked 263 } 264 265 @Override 266 public void executionSkipped(TestIdentifier testIdentifier, 267 String reason) { 268 // Not tracked 269 } 270 271 @Override 272 public void executionStarted(TestIdentifier testIdentifier) { 273 if (testIdentifier.getSource().isPresent() 274 && testIdentifier.getSource().get() instanceof ClassSource) { 275 tests.add(testIdentifier); 276 } 277 } 278 279 @Override 280 public void executionFinished(TestIdentifier testIdentifier, 281 TestExecutionResult testExecutionResult) { 282 if (testExecutionResult.getStatus() == Status.SUCCESSFUL) { 283 if (testIdentifier.getType() != Type.TEST) { 284 return; 285 } 286 logger.atFine().log("Succeeded: %s", 287 lazy(() -> prettyTestName(testIdentifier))); 288 return; 289 } 290 if (testExecutionResult.getThrowable().isEmpty()) { 291 logger.atWarning().log("Failed: %s", 292 lazy(() -> prettyTestName(testIdentifier))); 293 return; 294 } 295 logger.atWarning() 296 .withCause(testExecutionResult.getThrowable().get()) 297 .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier))); 298 } 299 300 @Override 301 public void reportingEntryPublished(TestIdentifier testIdentifier, 302 ReportEntry entry) { 303 // Not tracked 304 } 305 306 @Override 307 public void fileEntryPublished(TestIdentifier testIdentifier, 308 FileEntry file) { 309 // Not tracked 310 } 311 } 312}