GH-31 - Support for optimized test execution.
Initital prototype to support optimized test execution based on the Spring Modulith application module model. The change introduces a new artifact spring-modulith-junit that extends JUnit's test execution lifecycle. It obtains the ApplicationModules model for the application and potentially skips test classes for execution in case the changes made to the application reside in modules the current test case's module does not depend on. Co-authored-by: Lukas Dohmen <l.dohmen@yahoo.de> Co-authored-by: David Bilge <david.bilge@gmail.com>
This commit is contained in:
committed by
Oliver Drotbohm
parent
db4ab8f55d
commit
8cf3920af9
3
pom.xml
3
pom.xml
@@ -29,7 +29,8 @@
|
||||
<module>spring-modulith-runtime</module>
|
||||
<module>spring-modulith-starters</module>
|
||||
<module>spring-modulith-test</module>
|
||||
</modules>
|
||||
<module>spring-modulith-junit</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-junit</artifactId>
|
||||
<version>1.3.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
package example.order;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.modulith.test.ApplicationModuleTest;
|
||||
import org.springframework.modulith.test.Scenario;
|
||||
@@ -28,16 +27,16 @@ import org.springframework.modulith.test.Scenario;
|
||||
@RequiredArgsConstructor
|
||||
class OrderIntegrationTests {
|
||||
|
||||
private final OrderManagement orders;
|
||||
private final OrderManagement orders;
|
||||
|
||||
@Test
|
||||
void publishesOrderCompletion(Scenario scenario) {
|
||||
@Test
|
||||
void publishesOrderCompletion(Scenario scenario) {
|
||||
// this is a change
|
||||
var reference = new Order();
|
||||
|
||||
var reference = new Order();
|
||||
|
||||
scenario.stimulate(() -> orders.complete(reference))
|
||||
.andWaitForEventOfType(OrderCompleted.class)
|
||||
.matchingMappedValue(OrderCompleted::orderId, reference.getId())
|
||||
.toArrive();
|
||||
}
|
||||
scenario.stimulate(() -> orders.complete(reference))
|
||||
.andWaitForEventOfType(OrderCompleted.class)
|
||||
.matchingMappedValue(OrderCompleted::orderId, reference.getId())
|
||||
.toArrive();
|
||||
}
|
||||
}
|
||||
|
||||
57
spring-modulith-junit/pom.xml
Normal file
57
spring-modulith-junit/pom.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith</artifactId>
|
||||
<version>1.3.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<name>Spring Modulith - Test Junit</name>
|
||||
|
||||
<artifactId>spring-modulith-junit</artifactId>
|
||||
|
||||
<properties>
|
||||
<module.name>org.springframework.modulith.junit</module.name>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jgit</groupId>
|
||||
<artifactId>org.eclipse.jgit</artifactId>
|
||||
<version>6.8.0.202311291450-r</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.modulith</groupId>
|
||||
<artifactId>spring-modulith-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-test</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.springframework.modulith.junit;
|
||||
|
||||
sealed interface Change {
|
||||
record JavaClassChange(String fullyQualifiedClassName) implements Change {}
|
||||
|
||||
record JavaTestClassChange(String fullyQualifiedClassName) implements Change {}
|
||||
|
||||
record OtherFileChange(String path) implements Change {}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.springframework.modulith.junit;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.modulith.junit.Change.JavaClassChange;
|
||||
import org.springframework.modulith.junit.Change.JavaTestClassChange;
|
||||
import org.springframework.modulith.junit.Change.OtherFileChange;
|
||||
import org.springframework.modulith.junit.diff.ModifiedFilePath;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
final class Changes {
|
||||
private static final String STANDARD_SOURCE_DIRECTORY = "src/main/java";
|
||||
private static final String STANDARD_TEST_SOURCE_DIRECTORY = "src/test/java";
|
||||
|
||||
private Changes() {}
|
||||
|
||||
static Set<Change> toChanges(Set<ModifiedFilePath> modifiedFilePaths) {
|
||||
return modifiedFilePaths.stream().map(Changes::toChange).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
static Change toChange(ModifiedFilePath modifiedFilePath) {
|
||||
if ("java".equalsIgnoreCase(StringUtils.getFilenameExtension(modifiedFilePath.path()))) {
|
||||
String withoutExtension = StringUtils.stripFilenameExtension(modifiedFilePath.path());
|
||||
|
||||
int startOfMainDir = withoutExtension.indexOf(STANDARD_SOURCE_DIRECTORY);
|
||||
int startOfTestDir = withoutExtension.indexOf(STANDARD_TEST_SOURCE_DIRECTORY);
|
||||
|
||||
if (startOfTestDir > 0 && (startOfMainDir < 0 || startOfTestDir < startOfMainDir)) {
|
||||
String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName(
|
||||
withoutExtension.substring(startOfTestDir + STANDARD_TEST_SOURCE_DIRECTORY.length() + 1));
|
||||
|
||||
return new JavaTestClassChange(fullyQualifiedClassName);
|
||||
} else if (startOfMainDir > 0 && (startOfTestDir < 0 || startOfMainDir < startOfTestDir)) {
|
||||
String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName(
|
||||
withoutExtension.substring(startOfMainDir + STANDARD_SOURCE_DIRECTORY.length() + 1));
|
||||
|
||||
return new JavaClassChange(fullyQualifiedClassName);
|
||||
} else {
|
||||
// This is unusual, fall back to just assume that the full path is the package -> TODO At least log this
|
||||
String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName(withoutExtension);
|
||||
|
||||
return new JavaClassChange(fullyQualifiedClassName);
|
||||
}
|
||||
} else {
|
||||
// TODO Do these need to be relative to the module root (i.e. where src/main/java etc. reside)?
|
||||
return new OtherFileChange(modifiedFilePath.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.springframework.modulith.junit;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.test.context.AnnotatedClassFinder;
|
||||
import org.springframework.modulith.core.ApplicationModule;
|
||||
import org.springframework.modulith.core.ApplicationModuleDependency;
|
||||
import org.springframework.modulith.core.ApplicationModules;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
|
||||
// add logging to explain what happens (and why)
|
||||
|
||||
/**
|
||||
* Junit Extension to skip test execution if no changes happened in the module that the test belongs to.
|
||||
*
|
||||
* @author Lukas Dohmen, David Bilge
|
||||
*/
|
||||
public class ModulithExecutionExtension implements ExecutionCondition {
|
||||
public static final String CONFIG_PROPERTY_PREFIX = "spring.modulith.test";
|
||||
final AnnotatedClassFinder spaClassFinder = new AnnotatedClassFinder(SpringBootApplication.class);
|
||||
private static final Logger log = LoggerFactory.getLogger(ModulithExecutionExtension.class);
|
||||
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
|
||||
if (context.getTestMethod().isPresent()) {
|
||||
// Is there something similar liken @TestInstance(TestInstance.Lifecycle.PER_CLASS) for Extensions?
|
||||
return ConditionEvaluationResult.enabled("Enabled, only evaluating per class");
|
||||
}
|
||||
|
||||
StateStore stateStore = new StateStore(context);
|
||||
Set<Class<?>> changedClasses = stateStore.getChangedClasses();
|
||||
if (changedClasses.isEmpty()) {
|
||||
log.trace("No class changes found, running tests");
|
||||
return ConditionEvaluationResult.enabled("ModulithExecutionExtension: No changes detected");
|
||||
}
|
||||
|
||||
log.trace("Found following changed classes {}", changedClasses);
|
||||
|
||||
Optional<Class<?>> testClass = context.getTestClass();
|
||||
if (testClass.isPresent()) {
|
||||
if (changedClasses.contains(testClass.get())) {
|
||||
return ConditionEvaluationResult.enabled("ModulithExecutionExtension: Change in test class detected");
|
||||
}
|
||||
Class<?> mainClass = this.spaClassFinder.findFromClass(testClass.get());
|
||||
|
||||
if (mainClass == null) {// TODO:: Try with @ApplicationModuleTest -> main class
|
||||
return ConditionEvaluationResult.enabled(
|
||||
"ModulithExecutionExtension: Unable to locate SpringBootApplication Class");
|
||||
}
|
||||
ApplicationModules applicationModules = ApplicationModules.of(mainClass);
|
||||
|
||||
String packageName = ClassUtils.getPackageName(testClass.get());
|
||||
|
||||
// always run test if one of whitelisted files is modified (ant matching)
|
||||
Optional<ApplicationModule> optionalApplicationModule = applicationModules.getModuleForPackage(packageName);
|
||||
if (optionalApplicationModule.isPresent()) {
|
||||
|
||||
Set<JavaClass> dependentClasses = getAllDependentClasses(optionalApplicationModule.get(),
|
||||
applicationModules);
|
||||
|
||||
for (Class<?> changedClass : changedClasses) {
|
||||
|
||||
if (optionalApplicationModule.get().contains(changedClass)) {
|
||||
return ConditionEvaluationResult.enabled(
|
||||
"ModulithExecutionExtension: Changes in module detected, Executing tests");
|
||||
}
|
||||
|
||||
if (dependentClasses.stream()
|
||||
.anyMatch(applicationModule -> applicationModule.isEquivalentTo(changedClass))) {
|
||||
return ConditionEvaluationResult.enabled(
|
||||
"ModulithExecutionExtension: Changes in dependent module detected, Executing tests");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ConditionEvaluationResult.disabled(
|
||||
"ModulithExtension: No Changes detected in current module, executing tests");
|
||||
}
|
||||
|
||||
private Set<JavaClass> getAllDependentClasses(ApplicationModule applicationModule,
|
||||
ApplicationModules applicationModules) {
|
||||
|
||||
Set<ApplicationModule> dependentModules = new HashSet<>();
|
||||
dependentModules.add(applicationModule);
|
||||
this.getDependentModules(applicationModule, applicationModules, dependentModules);
|
||||
|
||||
return dependentModules.stream()
|
||||
.map(appModule -> appModule.getDependencies(applicationModules))
|
||||
.flatMap(applicationModuleDependencies -> applicationModuleDependencies.stream()
|
||||
.map(ApplicationModuleDependency::getTargetType))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private void getDependentModules(ApplicationModule applicationModule, ApplicationModules applicationModules,
|
||||
Set<ApplicationModule> modules) {
|
||||
|
||||
Set<ApplicationModule> applicationModuleDependencies = applicationModule.getDependencies(applicationModules)
|
||||
.stream()
|
||||
.map(ApplicationModuleDependency::getTargetModule)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
modules.addAll(applicationModuleDependencies);
|
||||
if (!applicationModuleDependencies.isEmpty()) {
|
||||
for (ApplicationModule applicationModuleDependency : applicationModuleDependencies) {
|
||||
this.getDependentModules(applicationModuleDependency, applicationModules, modules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.springframework.modulith.junit;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.modulith.junit.Change.JavaClassChange;
|
||||
import org.springframework.modulith.junit.Change.JavaTestClassChange;
|
||||
import org.springframework.modulith.junit.Change.OtherFileChange;
|
||||
import org.springframework.modulith.junit.diff.FileModificationDetector;
|
||||
import org.springframework.modulith.junit.diff.ModifiedFilePath;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
class StateStore {
|
||||
private static final Logger log = LoggerFactory.getLogger(StateStore.class);
|
||||
|
||||
private final ExtensionContext.Store store;
|
||||
|
||||
StateStore(ExtensionContext context) {
|
||||
store = context.getRoot().getStore(Namespace.create(ModulithExecutionExtension.class));
|
||||
}
|
||||
|
||||
Set<Class<?>> getChangedClasses() {
|
||||
// noinspection unchecked
|
||||
return (Set<Class<?>>) store.getOrComputeIfAbsent("changed-files", s -> {
|
||||
var environment = new StandardEnvironment();
|
||||
ConfigDataEnvironmentPostProcessor.applyTo(environment);
|
||||
|
||||
var detector = FileModificationDetector.loadFileModificationDetector(environment);
|
||||
try {
|
||||
Set<ModifiedFilePath> modifiedFiles = detector.getModifiedFiles(environment);
|
||||
Set<Change> changes = Changes.toChanges(modifiedFiles);
|
||||
return toChangedClasses(changes);
|
||||
} catch (Exception e) {
|
||||
log.error("ModulithExecutionExtension: Unable to fetch changed files, executing all tests", e);
|
||||
return Set.of();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Set<Class<?>> toChangedClasses(Set<Change> changes) {
|
||||
Set<Class<?>> changedClasses = new HashSet<>();
|
||||
for (Change change : changes) {
|
||||
if (change instanceof OtherFileChange) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String className;
|
||||
if (change instanceof JavaClassChange jcc) {
|
||||
className = jcc.fullyQualifiedClassName();
|
||||
} else if (change instanceof JavaTestClassChange jtcc) {
|
||||
className = jtcc.fullyQualifiedClassName();
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected change type: " + change.getClass());
|
||||
}
|
||||
|
||||
try {
|
||||
Class<?> aClass = ClassUtils.forName(className, null);
|
||||
changedClasses.add(aClass);
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.trace("ModulithExecutionExtension: Unable to find class \"{}\"", className);
|
||||
}
|
||||
}
|
||||
return changedClasses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
import static org.springframework.modulith.junit.ModulithExecutionExtension.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.core.env.PropertyResolver;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.modulith.junit.ModulithExecutionExtension;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
public interface FileModificationDetector {
|
||||
Logger log = LoggerFactory.getLogger(FileModificationDetector.class);
|
||||
|
||||
String CLASS_FILE_SUFFIX = ".java";
|
||||
String PACKAGE_PREFIX = "src.main.java";
|
||||
|
||||
Set<ModifiedFilePath> getModifiedFiles(@NonNull PropertyResolver propertyResolver)
|
||||
throws IOException, GitAPIException;
|
||||
|
||||
static FileModificationDetector loadFileModificationDetector(@NonNull PropertyResolver propertyResolver) {
|
||||
var detectorClassName = propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".file-modification-detector");
|
||||
var referenceCommit = ReferenceCommitDetector.getReferenceCommitProperty(propertyResolver);
|
||||
|
||||
if (StringUtils.hasText(detectorClassName)) {
|
||||
try {
|
||||
var strategyType = ClassUtils.forName(detectorClassName, ModulithExecutionExtension.class.getClassLoader());
|
||||
log.info("Found request via property for file modification detector '{}'", detectorClassName);
|
||||
return BeanUtils.instantiateClass(strategyType, FileModificationDetector.class);
|
||||
|
||||
} catch (ClassNotFoundException | LinkageError o_O) {
|
||||
throw new IllegalStateException(o_O);
|
||||
}
|
||||
} else if (StringUtils.hasText(referenceCommit)) {
|
||||
log.info("Found reference commit property. Using file modification detector '{}'",
|
||||
ReferenceCommitDetector.class.getName());
|
||||
return new ReferenceCommitDetector();
|
||||
}
|
||||
|
||||
log.info("Using default file modification detector '{}'", UnpushedCommitsDetector.class.getName());
|
||||
return new UnpushedCommitsDetector();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.diff.DiffEntry;
|
||||
import org.eclipse.jgit.lib.ObjectId;
|
||||
import org.eclipse.jgit.lib.ObjectReader;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevTree;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
|
||||
import org.eclipse.jgit.treewalk.AbstractTreeIterator;
|
||||
import org.eclipse.jgit.treewalk.CanonicalTreeParser;
|
||||
|
||||
/**
|
||||
* Utility to contain re-used JGit operations. For internal use only.
|
||||
*/
|
||||
final class JGitUtil {
|
||||
private JGitUtil() {}
|
||||
|
||||
static Stream<ModifiedFilePath> convertDiffEntriesToFileChanges(Stream<DiffEntry> diffEntries) {
|
||||
return diffEntries.flatMap(
|
||||
entry -> Stream.of(new ModifiedFilePath(entry.getNewPath()), new ModifiedFilePath(entry.getOldPath())))
|
||||
.filter(change -> !change.path().equals("/dev/null"));
|
||||
}
|
||||
|
||||
static Repository buildRepository() throws IOException {
|
||||
return new FileRepositoryBuilder().findGitDir().build();
|
||||
}
|
||||
|
||||
static Stream<DiffEntry> diffRefs(Repository repository, String oldRef, String newRef) throws IOException {
|
||||
try (Git git = new Git(repository)) {
|
||||
AbstractTreeIterator oldTreeParser = prepareTreeParser(repository, oldRef);
|
||||
AbstractTreeIterator newTreeParser = prepareTreeParser(repository, newRef);
|
||||
|
||||
return git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).call().stream();
|
||||
} catch (GitAPIException e) {
|
||||
throw new IOException("Unable to find diff between refs '%s' and '%s'".formatted(oldRef, newRef), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static AbstractTreeIterator prepareTreeParser(Repository repository, String ref) throws IOException {
|
||||
ObjectId commitId = repository.resolve(ref);
|
||||
|
||||
try (RevWalk walk = new RevWalk(repository)) {
|
||||
RevCommit commit = walk.parseCommit(commitId);
|
||||
RevTree tree = walk.parseTree(commit.getTree().getId());
|
||||
|
||||
CanonicalTreeParser treeParser = new CanonicalTreeParser();
|
||||
try (ObjectReader reader = repository.newObjectReader()) {
|
||||
treeParser.reset(reader, tree.getId());
|
||||
}
|
||||
|
||||
return treeParser;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
public record ModifiedFilePath(String path) {}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
import static org.springframework.modulith.junit.ModulithExecutionExtension.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jgit.diff.DiffEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.env.PropertyResolver;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
/**
|
||||
* Implementation to get changes between HEAD and a complete or abbreviated SHA-1 or other revision, like
|
||||
* <code>HEAD~2</code>. See {@link org.eclipse.jgit.lib.Repository#resolve(String)} for more information.
|
||||
*/
|
||||
public class ReferenceCommitDetector implements FileModificationDetector {
|
||||
private static final Logger log = LoggerFactory.getLogger(ReferenceCommitDetector.class);
|
||||
|
||||
@Override
|
||||
public @NonNull Set<ModifiedFilePath> getModifiedFiles(@NonNull PropertyResolver propertyResolver)
|
||||
throws IOException {
|
||||
String commitIdToCompareTo = getReferenceCommitProperty(propertyResolver);
|
||||
|
||||
try (var repo = JGitUtil.buildRepository()) {
|
||||
String compareTo;
|
||||
if (commitIdToCompareTo == null || commitIdToCompareTo.isEmpty()) {
|
||||
log.warn("No reference-commit configured, comparing to HEAD~1.");
|
||||
compareTo = "HEAD~1";
|
||||
} else {
|
||||
log.info("Comparing to git commit #{}", commitIdToCompareTo);
|
||||
compareTo = commitIdToCompareTo;
|
||||
}
|
||||
|
||||
String localBranch = repo.getFullBranch();
|
||||
Stream<DiffEntry> diffs = JGitUtil.diffRefs(repo, compareTo, localBranch);
|
||||
|
||||
return JGitUtil.convertDiffEntriesToFileChanges(diffs).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
||||
public static String getReferenceCommitProperty(PropertyResolver propertyResolver) {
|
||||
return propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".reference-commit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.Status;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
|
||||
import org.springframework.core.env.PropertyResolver;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
import com.tngtech.archunit.thirdparty.com.google.common.collect.Streams;
|
||||
|
||||
/**
|
||||
* Implementation to get latest local file changes.
|
||||
*
|
||||
* @author Lukas Dohmen
|
||||
*/
|
||||
public class UncommittedChangesDetector implements FileModificationDetector {
|
||||
|
||||
@Override
|
||||
public @NonNull Set<ModifiedFilePath> getModifiedFiles(@NonNull PropertyResolver propertyResolver)
|
||||
throws IOException {
|
||||
|
||||
try (var repo = new FileRepositoryBuilder().findGitDir().build()) {
|
||||
return findUncommittedChanges(repo).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream<ModifiedFilePath> findUncommittedChanges(Repository repository) throws IOException {
|
||||
try (Git git = new Git(repository)) {
|
||||
Status status = git.status().call();
|
||||
|
||||
return Streams.concat(status.getUncommittedChanges().stream(), status.getUntracked().stream())
|
||||
.map(ModifiedFilePath::new);
|
||||
} catch (GitAPIException e) {
|
||||
throw new IOException("Unable to find uncommitted changes", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.springframework.modulith.junit.diff;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jgit.diff.DiffEntry;
|
||||
import org.eclipse.jgit.lib.BranchConfig;
|
||||
import org.springframework.core.env.PropertyResolver;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Find all changes that have not been pushed to the remote branch yet.
|
||||
* <p>
|
||||
* To be precise, this finds the diff between the local HEAD and its tracking branch and the uncommitted and untracked
|
||||
* changes. <em>Note:</em> This will not fetch from the remote first!
|
||||
*/
|
||||
public class UnpushedCommitsDetector implements FileModificationDetector {
|
||||
|
||||
@Override
|
||||
public @NonNull Set<ModifiedFilePath> getModifiedFiles(@NonNull PropertyResolver propertyResolver)
|
||||
throws IOException {
|
||||
try (var repo = JGitUtil.buildRepository()) {
|
||||
String localBranch = repo.getFullBranch();
|
||||
String trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch();
|
||||
|
||||
Stream<DiffEntry> diff = localBranch != null && trackingBranch != null
|
||||
? JGitUtil.diffRefs(repo, localBranch, trackingBranch)
|
||||
: Stream.empty();
|
||||
|
||||
HashSet<ModifiedFilePath> result = new HashSet<>();
|
||||
result.addAll(new UncommittedChangesDetector().getModifiedFiles(propertyResolver));
|
||||
result.addAll(JGitUtil.convertDiffEntriesToFileChanges(diff).collect(Collectors.toSet()));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"name": "spring.modulith.test",
|
||||
"description": "Properties configuring the execution of modulith-specific tests."
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "spring.modulith.test.changed-files-strategy",
|
||||
"type": "java.lang.String",
|
||||
"defaultValue": "org.springframework.modulith.UncommittedChangesStrategy",
|
||||
"description": "A strategy to determine the list of changed files to consider for test execution. This should be a fully qualified class name of a registered implementation."
|
||||
}
|
||||
],
|
||||
"hints": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.springframework.modulith.junit.ModulithExecutionExtension
|
||||
@@ -0,0 +1,3 @@
|
||||
org.springframework.modulith.git.UncommittedChangesDetector
|
||||
org.springframework.modulith.git.UnpushedGitChangesDetector
|
||||
org.springframework.modulith.git.DiffDetector
|
||||
@@ -0,0 +1 @@
|
||||
junit.jupiter.extensions.autodetection.enabled=true
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.springframework.modulith.junit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.modulith.junit.Change.JavaClassChange;
|
||||
import org.springframework.modulith.junit.Change.JavaTestClassChange;
|
||||
import org.springframework.modulith.junit.Change.OtherFileChange;
|
||||
import org.springframework.modulith.junit.diff.ModifiedFilePath;
|
||||
|
||||
class ChangesTest {
|
||||
@Test
|
||||
void shouldInterpredModifiedFilePathsCorrectly() {
|
||||
// given
|
||||
Set<ModifiedFilePath> modifiedFilePaths = Set.of(
|
||||
new ModifiedFilePath("spring-modulith-junit/src/main/java/org/springframework/modulith/Changes.java"),
|
||||
new ModifiedFilePath("spring-modulith-junit/src/test/java/org/springframework/modulith/ChangesTest.java"),
|
||||
new ModifiedFilePath(
|
||||
"spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json"));
|
||||
|
||||
// when
|
||||
Set<Change> result = Changes.toChanges(modifiedFilePaths);
|
||||
|
||||
// then
|
||||
assertThat(result).containsExactlyInAnyOrder(
|
||||
new JavaClassChange("org.springframework.modulith.Changes"),
|
||||
new JavaTestClassChange("org.springframework.modulith.ChangesTest"),
|
||||
new OtherFileChange(
|
||||
"spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user