Skip to content

Commit f1c00a0

Browse files
committed
Spring DistributionProvider
1 parent 3b8d508 commit f1c00a0

File tree

8 files changed

+385
-1
lines changed

8 files changed

+385
-1
lines changed

maven-test-distribution-plugin/src/main/java/com/github/seregamorph/testdistribution/maven/SplitMojo.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ private List<List<String>> splitTestClasses(Collection<URL> urls, List<String> t
143143
ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
144144
ClassLoader pluginClassLoader = SplitMojo.class.getClassLoader();
145145
try (URLClassLoader classLoader = new URLClassLoader(urls.toArray(new URL[0]), pluginClassLoader)) {
146+
Thread.currentThread().setContextClassLoader(classLoader);
146147
Class<? extends DistributionProvider> distributionProviderClass = classLoader.loadClass(distributionProvider).asSubclass(DistributionProvider.class);
147148

148-
Thread.currentThread().setContextClassLoader(classLoader);
149149
DistributionProvider distributionProvider = distributionProviderClass.getConstructor().newInstance();
150150
return distributionProvider.split(testClasses, parameters);
151151
} catch (IOException | ReflectiveOperationException e) {

pom.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,14 @@
5555
<maven-release-plugin.version>2.5.3</maven-release-plugin.version>
5656
<maven-gpg-plugin.version>1.6</maven-gpg-plugin.version>
5757

58+
<slf4j.version>1.7.36</slf4j.version>
59+
<spring.version>6.1.16</spring.version>
5860
<jackson.version>2.17.2</jackson.version>
5961
</properties>
6062

6163
<modules>
6264
<module>maven-test-distribution-plugin</module>
65+
<module>test-distribution-spring-provider</module>
6366
<module>test-distribution-provider</module>
6467
</modules>
6568

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
2+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
5+
<parent>
6+
<groupId>com.github.seregamorph</groupId>
7+
<artifactId>test-distribution</artifactId>
8+
<version>0.6-SNAPSHOT</version>
9+
</parent>
10+
11+
<artifactId>test-distribution-spring-provider</artifactId>
12+
<packaging>jar</packaging>
13+
<description>
14+
Test class distribution for Spring Integration tests
15+
</description>
16+
17+
<developers>
18+
<developer>
19+
<id>seregamorph</id>
20+
<name>Sergey Chernov</name>
21+
<email>serega.morph[at]gmail.com</email>
22+
</developer>
23+
</developers>
24+
25+
<licenses>
26+
<license>
27+
<name>Apache License, Version 2.0</name>
28+
<url>http://www.apache.org/licenses/LICENSE-2.0</url>
29+
<distribution>repo</distribution>
30+
</license>
31+
</licenses>
32+
33+
<url>https://github.com/seregamorph/test-distribution</url>
34+
35+
<dependencies>
36+
<dependency>
37+
<groupId>com.github.seregamorph</groupId>
38+
<artifactId>test-distribution-provider</artifactId>
39+
</dependency>
40+
41+
<dependency>
42+
<groupId>org.slf4j</groupId>
43+
<artifactId>slf4j-api</artifactId>
44+
<version>${slf4j.version}</version>
45+
</dependency>
46+
47+
<dependency>
48+
<groupId>junit</groupId>
49+
<artifactId>junit</artifactId>
50+
<version>4.13.2</version>
51+
<scope>provided</scope>
52+
</dependency>
53+
54+
<dependency>
55+
<groupId>org.junit.jupiter</groupId>
56+
<artifactId>junit-jupiter-api</artifactId>
57+
<version>5.12.1</version>
58+
<scope>provided</scope>
59+
</dependency>
60+
61+
<dependency>
62+
<groupId>org.springframework</groupId>
63+
<artifactId>spring-context</artifactId>
64+
<version>${spring.version}</version>
65+
<scope>provided</scope>
66+
</dependency>
67+
<dependency>
68+
<groupId>org.springframework</groupId>
69+
<artifactId>spring-test</artifactId>
70+
<version>${spring.version}</version>
71+
<scope>provided</scope>
72+
</dependency>
73+
</dependencies>
74+
</project>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.github.seregamorph.testdistribution.spring;
2+
3+
import java.util.List;
4+
import java.util.stream.Collectors;
5+
6+
/**
7+
* @author Sergey Chernov
8+
*/
9+
final class ClassUtils {
10+
11+
static List<Class<?>> classNamesToClasses(List<String> classNames, boolean initialize) {
12+
return classNames.stream()
13+
.map(testClassName -> classForName(testClassName, initialize,
14+
SpringDistributionProvider.class.getClassLoader()))
15+
.collect(Collectors.toList());
16+
}
17+
18+
private static Class<?> classForName(String className, boolean initialize, ClassLoader classLoader) {
19+
try {
20+
return Class.forName(className, initialize, classLoader);
21+
} catch (ClassNotFoundException e) {
22+
throw new IllegalStateException("Failed to load class [" + className + "] with classLoader " + className);
23+
}
24+
}
25+
26+
static List<String> classesToClassNames(List<Class<?>> classes) {
27+
return classes.stream()
28+
.map(Class::getName)
29+
.collect(Collectors.toList());
30+
}
31+
32+
private ClassUtils() {
33+
}
34+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.github.seregamorph.testdistribution.spring;
2+
3+
import org.junit.jupiter.api.extension.ExtendWith;
4+
import org.junit.runner.RunWith;
5+
import org.junit.runner.Runner;
6+
import org.springframework.context.ApplicationContextAware;
7+
import org.springframework.core.annotation.AnnotatedElementUtils;
8+
import org.springframework.test.context.junit.jupiter.SpringExtension;
9+
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
10+
11+
import java.lang.reflect.Modifier;
12+
import java.util.Arrays;
13+
import java.util.Iterator;
14+
import java.util.ServiceLoader;
15+
import java.util.Set;
16+
17+
/**
18+
* Integration Test class filter that should be ordered (as they use spring context). The logic of this class can be
19+
* customized via
20+
* <pre>
21+
* META-INF/services/com.github.seregamorph.testdistribution.spring.IntegrationTestFilter
22+
* </pre>
23+
* defining subtype of this class overriding methods.
24+
* Origin: https://github.com/seregamorph/spring-test-smart-context/blob/master/spring-test-smart-context/src/main/java/com/github/seregamorph/testsmartcontext/IntegrationTestFilter.java
25+
*
26+
* @author Sergey Chernov
27+
*/
28+
public class IntegrationTestFilter {
29+
30+
private static final IntegrationTestFilter instance = initInstance();
31+
32+
private static IntegrationTestFilter initInstance() {
33+
ServiceLoader<IntegrationTestFilter> serviceLoader = ServiceLoader.load(IntegrationTestFilter.class,
34+
IntegrationTestFilter.class.getClassLoader());
35+
Iterator<IntegrationTestFilter> iterator = serviceLoader.iterator();
36+
if (iterator.hasNext()) {
37+
return iterator.next();
38+
} else {
39+
return new IntegrationTestFilter();
40+
}
41+
}
42+
43+
public static IntegrationTestFilter getInstance() {
44+
return instance;
45+
}
46+
47+
protected IntegrationTestFilter() {
48+
}
49+
50+
protected boolean isIntegrationTest(Class<?> testClass) {
51+
if (Modifier.isAbstract(testClass.getModifiers())) {
52+
return false;
53+
}
54+
55+
if (ApplicationContextAware.class.isAssignableFrom(testClass)) {
56+
// Subtypes of org.springframework.test.context.testng.AbstractTestNGSpringContextTests
57+
// and org.springframework.test.context.junit4.AbstractJUnit4SpringContextTests
58+
return true;
59+
}
60+
61+
if (JUnitPlatformSupport.isJunit4Present() && isIntegrationTestJUnit4(testClass)) {
62+
return true;
63+
}
64+
65+
//noinspection RedundantIfStatement
66+
if (JUnitPlatformSupport.isJunit5JupiterApiPresent() && isIntegrationTestJUnit5Jupiter(testClass)) {
67+
return true;
68+
}
69+
70+
return false;
71+
}
72+
73+
/**
74+
* This method should be only called if JUnit4 is on the classpath
75+
*/
76+
protected boolean isIntegrationTestJUnit4(Class<?> testClass) {
77+
// can be inherited, but cannot be meta-annotation
78+
RunWith runWith = testClass.getAnnotation(RunWith.class);
79+
if (runWith == null) {
80+
return false;
81+
}
82+
Class<? extends Runner> runner = runWith.value();
83+
// includes org.springframework.test.context.junit4.SpringRunner
84+
return SpringJUnit4ClassRunner.class.isAssignableFrom(runner);
85+
}
86+
87+
/**
88+
* This method should be only called if JUnit5 Jupiter API is on the classpath
89+
*/
90+
protected boolean isIntegrationTestJUnit5Jupiter(Class<?> testClass) {
91+
// can be inherited, can be meta-annotation e.g. via @SpringBootTest
92+
Set<ExtendWith> extendWith = AnnotatedElementUtils.findAllMergedAnnotations(testClass, ExtendWith.class);
93+
if (extendWith.isEmpty()) {
94+
return false;
95+
}
96+
97+
return extendWith.stream()
98+
.map(ExtendWith::value)
99+
.flatMap(Arrays::stream)
100+
.anyMatch(SpringExtension.class::isAssignableFrom);
101+
}
102+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.github.seregamorph.testdistribution.spring;
2+
3+
import org.springframework.util.ClassUtils;
4+
5+
/**
6+
* Origin: https://github.com/seregamorph/spring-test-smart-context/blob/master/spring-test-smart-context/src/main/java/com/github/seregamorph/testsmartcontext/JUnitPlatformSupport.java
7+
*
8+
* @author Sergey Chernov
9+
*/
10+
final class JUnitPlatformSupport {
11+
12+
@Deprecated
13+
private static final boolean JUNIT4_PRESENT = isClassPresent(
14+
"org.junit.runner.RunWith");
15+
16+
private static final boolean JUNIT5_JUPITER_API_PRESENT = isClassPresent(
17+
"org.junit.jupiter.api.extension.ExtendWith");
18+
19+
private static final boolean JUNIT4_IDEA_TEST_RUNNER_PRESENT = isClassPresent(
20+
"com.intellij.junit4.JUnit4IdeaTestRunner");
21+
22+
@Deprecated
23+
static boolean isJunit4Present() {
24+
return JUNIT4_PRESENT;
25+
}
26+
27+
static boolean isJunit5JupiterApiPresent() {
28+
return JUNIT5_JUPITER_API_PRESENT;
29+
}
30+
31+
private static boolean isClassPresent(String className) {
32+
return ClassUtils.isPresent(className, JUnitPlatformSupport.class.getClassLoader());
33+
}
34+
35+
private JUnitPlatformSupport() {
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.github.seregamorph.testdistribution.spring;
2+
3+
import com.github.seregamorph.testdistribution.DistributionProvider;
4+
import com.github.seregamorph.testdistribution.SimpleDistributionProvider;
5+
import com.github.seregamorph.testdistribution.TestDistributionParameters;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.test.context.BootstrapUtilsHelper;
9+
import org.springframework.test.context.MergedContextConfiguration;
10+
import org.springframework.test.context.TestContextBootstrapper;
11+
12+
import java.util.*;
13+
import java.util.concurrent.atomic.AtomicInteger;
14+
import java.util.stream.Collectors;
15+
16+
import static java.util.Comparator.comparing;
17+
18+
/**
19+
* Test class distribution provider for Spring Integration tests based on calculated MergedContextConfiguration.
20+
* <p>
21+
* See more details <a href="https://github.com/seregamorph/spring-test-smart-context">spring-test-smart-context</a>
22+
*
23+
* @author Sergey Chernov
24+
*/
25+
public class SpringDistributionProvider implements DistributionProvider {
26+
27+
private static final Logger logger = LoggerFactory.getLogger(SpringDistributionProvider.class);
28+
29+
@Override
30+
public List<List<String>> split(List<String> testClassNames, TestDistributionParameters parameters) {
31+
if (testClassNames.isEmpty()) {
32+
logger.info("No matching test classes found in the classpath");
33+
return new SimpleDistributionProvider().split(testClassNames, parameters);
34+
}
35+
36+
try {
37+
Class.forName("org.springframework.test.context.BootstrapUtils", true,
38+
SpringDistributionProvider.class.getClassLoader());
39+
Class.forName("org.springframework.context.ApplicationContextAware", true,
40+
SpringDistributionProvider.class.getClassLoader());
41+
} catch (ClassNotFoundException e) {
42+
logger.info("Missing spring framework in the classpath, fallback to default provider");
43+
return new SimpleDistributionProvider().split(testClassNames, parameters);
44+
}
45+
46+
List<Class<?>> testClasses = ClassUtils.classNamesToClasses(testClassNames, false);
47+
Set<Class<?>> itClasses = filterItClasses(testClasses);
48+
49+
Map<MergedContextConfiguration, TestClasses> configToTests = new LinkedHashMap<>();
50+
Map<Class<?>, Integer> classToOrder = new LinkedHashMap<>();
51+
AtomicInteger orderCounter = new AtomicInteger();
52+
for (Class<?> itClass : itClasses) {
53+
TestContextBootstrapper bootstrapper = BootstrapUtilsHelper.resolveTestContextBootstrapper(itClass);
54+
MergedContextConfiguration mergedContextConfiguration = bootstrapper.buildMergedContextConfiguration();
55+
// Sequentially each unique mergedContextConfiguration will have own order
56+
// via orderCounter. initial order values all have a gap to allow for moving
57+
// @DirtiesContext annotated classes to the back later.
58+
TestClasses configurationTestClasses = configToTests.computeIfAbsent(mergedContextConfiguration,
59+
$ -> new TestClasses(orderCounter.incrementAndGet(), new LinkedHashSet<>()));
60+
configurationTestClasses.classes.add(itClass);
61+
classToOrder.put(itClass, configurationTestClasses.order);
62+
}
63+
64+
if (configToTests.isEmpty()) {
65+
logger.info("Splitting {} test classes, none of them are integration tests", testClasses.size());
66+
} else {
67+
logger.info("Splitting {} test classes, {} IT classes are detected with {} separate configurations",
68+
testClasses.size(), itClasses.size(), configToTests.size());
69+
}
70+
71+
List<Class<?>> sortedTestClasses = testClasses.stream().sorted(comparing(testClass -> {
72+
Integer order = classToOrder.get(testClass);
73+
if (order == null) {
74+
// all non-IT tests go first (other returned values are non-zero)
75+
// this logic can be changed via override
76+
return getNonItOrder();
77+
} else {
78+
// this sorting is stable - most of the tests will preserve alphabetical ordering where possible
79+
// we only sort classes that shut down the context to the end.
80+
return order;
81+
}
82+
})).collect(Collectors.toList());
83+
84+
return new SimpleDistributionProvider().split(ClassUtils.classesToClassNames(sortedTestClasses), parameters);
85+
}
86+
87+
/**
88+
* Get the order of non-integration test execution (bigger is later). Can be either first or last. 0 (first) by
89+
* default.
90+
*/
91+
protected int getNonItOrder() {
92+
return 0;
93+
}
94+
95+
private static Set<Class<?>> filterItClasses(List<Class<?>> testClasses) {
96+
IntegrationTestFilter integrationTestFilter = IntegrationTestFilter.getInstance();
97+
Set<Class<?>> itClasses = new LinkedHashSet<>();
98+
for (Class<?> testClass : testClasses) {
99+
if (!itClasses.contains(testClass) && integrationTestFilter.isIntegrationTest(testClass)) {
100+
itClasses.add(testClass);
101+
}
102+
}
103+
return itClasses;
104+
}
105+
106+
private static final class TestClasses {
107+
108+
private final int order;
109+
private final Set<Class<?>> classes;
110+
111+
private TestClasses(int order, Set<Class<?>> classes) {
112+
this.order = order;
113+
this.classes = classes;
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)