From 0a1240efa1dc11940eee98a0b96683878f98dd72 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Fri, 6 Sep 2024 15:01:57 +0200 Subject: [PATCH] GH-31 - Polishing. Added unit tests for the test execution conditions. Add JUnit module to the BOM. Moved the jGit version into a managed property in the root pom.xml. --- pom.xml | 1 + spring-modulith-bom/pom.xml | 5 + .../spring-modulith-example-full/pom.xml | 3 +- spring-modulith-integration-test/pom.xml | 7 + .../java/com/acme/myproject/ModulithTest.java | 2 +- .../acme/myproject/moduleD/ModuleDTest.java | 2 +- .../TestExecutionConditionUnitTests.java | 96 ++++++ spring-modulith-junit/pom.xml | 4 +- .../modulith/junit/Change.java | 9 - .../modulith/junit/Changes.java | 302 ++++++++++++++++-- .../junit/ModulithExecutionCondition.java | 85 +++++ .../junit/ModulithExecutionExtension.java | 122 ------- .../modulith/junit/StateStore.java | 101 +++--- .../junit/TestExecutionCondition.java | 125 ++++++++ .../junit/diff/FileModificationDetector.java | 109 +++++-- .../modulith/junit/diff/JGitUtil.java | 73 +++-- .../modulith/junit/diff/ModifiedFile.java | 42 +++ .../modulith/junit/diff/ModifiedFilePath.java | 3 - .../junit/diff/ReferenceCommitDetector.java | 83 +++-- .../diff/UncommittedChangesDetector.java | 58 ++-- .../junit/diff/UnpushedCommitsDetector.java | 61 ++-- ...itional-spring-configuration-metadata.json | 17 - .../org.junit.jupiter.api.extension.Extension | 2 +- ...ramework.modulith.FileModificationDetector | 3 - .../spring-configuration-metadata.json | 47 +++ .../src/test/java/example/a/package-info.java | 1 + .../src/test/java/example/b/package-info.java | 1 + .../src/test/java/example/package-info.java | 1 + .../modulith/junit/ChangeTest.java | 33 -- .../modulith/junit/ChangesUnitTests.java | 83 +++++ .../FileModificationDetectorUnitTests.java | 102 ++++++ .../ReferenceCommitDetectorUnitTests.java | 36 +++ 32 files changed, 1227 insertions(+), 392 deletions(-) create mode 100644 spring-modulith-integration-test/src/test/java/org/springframework/modulith/junit/TestExecutionConditionUnitTests.java delete mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionCondition.java delete mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/TestExecutionCondition.java create mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFile.java delete mode 100644 spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java delete mode 100644 spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json delete mode 100644 spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector create mode 100644 spring-modulith-junit/src/main/resources/META-INF/spring-configuration-metadata.json create mode 100644 spring-modulith-junit/src/test/java/example/a/package-info.java create mode 100644 spring-modulith-junit/src/test/java/example/b/package-info.java create mode 100644 spring-modulith-junit/src/test/java/example/package-info.java delete mode 100644 spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java create mode 100644 spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangesUnitTests.java create mode 100644 spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/FileModificationDetectorUnitTests.java create mode 100644 spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java diff --git a/pom.xml b/pom.xml index 0526c6f2..5054f8df 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,7 @@ 1.3.0 3.6.2 4.14.0 + 6.10.0.202406032230-r 1.5.2 2023.1.4 UTF-8 diff --git a/spring-modulith-bom/pom.xml b/spring-modulith-bom/pom.xml index 4caa1c02..5aa0b9ef 100644 --- a/spring-modulith-bom/pom.xml +++ b/spring-modulith-bom/pom.xml @@ -99,6 +99,11 @@ spring-modulith-events-tests 1.3.0-GH-31-SNAPSHOT + + org.springframework.modulith + spring-modulith-junit + 1.3.0-GH-31-SNAPSHOT + org.springframework.modulith spring-modulith-moments diff --git a/spring-modulith-examples/spring-modulith-example-full/pom.xml b/spring-modulith-examples/spring-modulith-example-full/pom.xml index f9499ab3..4e6816b9 100644 --- a/spring-modulith-examples/spring-modulith-example-full/pom.xml +++ b/spring-modulith-examples/spring-modulith-example-full/pom.xml @@ -63,12 +63,13 @@ spring-boot-configuration-processor true + org.springframework.modulith spring-modulith-junit - 1.3.0-SNAPSHOT test + diff --git a/spring-modulith-integration-test/pom.xml b/spring-modulith-integration-test/pom.xml index 170d5b70..a45c4286 100644 --- a/spring-modulith-integration-test/pom.xml +++ b/spring-modulith-integration-test/pom.xml @@ -59,6 +59,13 @@ + + org.springframework.modulith + spring-modulith-junit + ${project.version} + test + + org.springframework.boot spring-boot-starter-test diff --git a/spring-modulith-integration-test/src/test/java/com/acme/myproject/ModulithTest.java b/spring-modulith-integration-test/src/test/java/com/acme/myproject/ModulithTest.java index 7b874c8d..b3acb034 100644 --- a/spring-modulith-integration-test/src/test/java/com/acme/myproject/ModulithTest.java +++ b/spring-modulith-integration-test/src/test/java/com/acme/myproject/ModulithTest.java @@ -37,7 +37,7 @@ * @author Oliver Drotbohm * @author Peter Gafert */ -class ModulithTest { +public class ModulithTest { static final DescribedPredicate DEFAULT_EXCLUSIONS = Filters.withoutModules("cycleA", "cycleB", "invalid2", "invalid3", "fieldinjected"); diff --git a/spring-modulith-integration-test/src/test/java/com/acme/myproject/moduleD/ModuleDTest.java b/spring-modulith-integration-test/src/test/java/com/acme/myproject/moduleD/ModuleDTest.java index 682d2e9c..9db43bfd 100644 --- a/spring-modulith-integration-test/src/test/java/com/acme/myproject/moduleD/ModuleDTest.java +++ b/spring-modulith-integration-test/src/test/java/com/acme/myproject/moduleD/ModuleDTest.java @@ -28,7 +28,7 @@ * @author Oliver Drotbohm */ @NonVerifyingModuleTest -class ModuleDTest { +public class ModuleDTest { @Autowired ConfigurableApplicationContext context; diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/junit/TestExecutionConditionUnitTests.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/junit/TestExecutionConditionUnitTests.java new file mode 100644 index 00000000..9403a44f --- /dev/null +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/junit/TestExecutionConditionUnitTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.springframework.modulith.junit.TestExecutionCondition.ConditionContext; +import org.springframework.modulith.junit.diff.ModifiedFile; + +import com.acme.myproject.ModulithTest; +import com.acme.myproject.moduleD.ModuleDTest; + +/** + * Unit tests for {@link TestExecutionCondition}. + * + * @author Oliver Drotbohm + */ +class TestExecutionConditionUnitTests { + + TestExecutionCondition condition = new TestExecutionCondition(); + + @Test // GH-31 + void enablesForSourceChangeInSameModulen() { + assertEnabled(ModuleDTest.class, "moduleD/SomeConfigurationD.java"); + } + + @Test // GH-31 + void enablesForSourceChangeInModuleDirectlyDependedOn() { + assertEnabled(ModuleDTest.class, "moduleC/ServiceComponentC.java"); + } + + @Test // GH-31 + void enablesForSourceChangeInModuleIndirectlyDependedOn() { + assertEnabled(ModuleDTest.class, "moduleB/ServiceComponentB.java"); + } + + @Test // GH-31 + void disablesForChangesInUnrelatedModule() { + assertDisabled(ModuleDTest.class, "moduleB/ServiceComponentE.java"); + } + + @Test // GH-31 + void enablesTestInRootModule() { + assertEnabled(ModulithTest.class); + } + + @Test // GH-31 + void enablesForClasspathFileChange() { + + var pomXml = new ModifiedFile("pom.xml"); + + assertEnabled(ModuleDTest.class, true, Stream.of(pomXml)); + } + + private void assertEnabled(Class type, String... files) { + assertEnabled(type, true, files); + } + + private void assertDisabled(Class type, String... files) { + assertEnabled(type, false, files); + } + + private void assertEnabled(Class type, boolean expected, String... files) { + + var modifiedFiles = Arrays.stream(files) + .map("src/main/java/com/acme/myproject/"::concat) + .map(ModifiedFile::new); + + assertEnabled(type, expected, modifiedFiles); + } + + private void assertEnabled(Class type, boolean expected, Stream files) { + + assertThat(condition.evaluate(new ConditionContext(type, Changes.of(files)))) + .extracting(ConditionEvaluationResult::isDisabled) + .isNotEqualTo(expected); + } +} diff --git a/spring-modulith-junit/pom.xml b/spring-modulith-junit/pom.xml index c96392ca..721091cc 100644 --- a/spring-modulith-junit/pom.xml +++ b/spring-modulith-junit/pom.xml @@ -10,7 +10,7 @@ ../pom.xml - Spring Modulith - Test Junit + Spring Modulith - JUnit Integration spring-modulith-junit @@ -22,7 +22,7 @@ org.eclipse.jgit org.eclipse.jgit - 6.8.0.202311291450-r + ${jgit.version} org.springframework.modulith diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java deleted file mode 100644 index 821bdc76..00000000 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Change.java +++ /dev/null @@ -1,9 +0,0 @@ -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 {} -} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java index a4dab76f..d09b6821 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/Changes.java @@ -1,51 +1,293 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.modulith.junit; +import static java.util.stream.Collectors.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; -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.modulith.junit.Changes.Change; +import org.springframework.modulith.junit.Changes.Change.OtherFileChange; +import org.springframework.modulith.junit.Changes.Change.SourceChange; +import org.springframework.modulith.junit.diff.ModifiedFile; +import org.springframework.util.Assert; 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"; +/** + * A set of {@link Change}s made to a Java project. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ +public class Changes implements Iterable { + + public static final Changes NONE = new Changes(Collections.emptySet()); + + private final Collection changes; + + /** + * Creates a new {@link Changes} instance from the given {@link Change}s. + * + * @param changes must not be {@literal null}. + */ + private Changes(Collection changes) { + + Assert.notNull(changes, "Changes must not be null!"); + + this.changes = changes; + } + + /** + * Creates a new {@link Changes} instance from the given {@link ModifiedFile}s. + * + * @param files must not be {@literal null}. + * @return will never be {@literal null}. + */ + static Changes of(Stream files) { + + Assert.notNull(files, "Modified files must not be null!"); + + return files.map(Change::of).collect(collectingAndThen(toSet(), Changes::new)); + } + + /** + * Returns whether there are any changes at all. + */ + boolean isEmpty() { + return changes.isEmpty(); + } + + Set getChangedClasses() { + + return filter(changes, SourceChange.class, SourceChange::fullyQualifiedClassName) + .collect(Collectors.toSet()); + } + + boolean hasClassChanges() { + return !getChangedClasses().isEmpty(); + } + + boolean contains(Class type) { + return changes.stream().anyMatch(it -> it.hasOrigin(type.getName())); + } + + /** + * Returns whether a build-related resource has changed. + * + * @return + */ + boolean hasBuildResourceChanges() { + + return filter(changes, OtherFileChange.class) + .anyMatch(OtherFileChange::affectsBuildResource); + } + + /** + * Returns whether the current changes contain a change to a classpath resource, i.e. a non-source file that lives on + * the classpath. + */ + boolean hasClasspathResourceChange() { + return changes.stream().anyMatch(it -> it instanceof OtherFileChange change && change.isClasspathResource()); + } + + /* + * (non-Javadoc) + * @see java.lang.Iterable#iterator() + */ + @Override + public Iterator iterator() { + return changes.iterator(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + + return changes.stream() + .map(Object::toString) + .collect(Collectors.joining("\n")); + } + + private static final Stream filter(Collection source, Class type) { + return filter(source, type, Function.identity()); + } - private Changes() {} + private static final Stream filter(Collection source, Class type, Function mapper) { - static Set toChanges(Set modifiedFilePaths) { - return modifiedFilePaths.stream().map(Changes::toChange).collect(Collectors.toSet()); + return source.stream() + .filter(type::isInstance) + .map(type::cast) + .map(mapper); } - static Change toChange(ModifiedFilePath modifiedFilePath) { - if ("java".equalsIgnoreCase(StringUtils.getFilenameExtension(modifiedFilePath.path()))) { - String withoutExtension = StringUtils.stripFilenameExtension(modifiedFilePath.path()); + /** + * A change to the local project. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ + sealed interface Change permits SourceChange, OtherFileChange { + + /** + * Returns whether the change has the given origin. + * + * @param nameOrPath must not be {@literal null} or empty. + */ + boolean hasOrigin(String nameOrPath); + + /** + * creates a new + * + * @param file + * @return + */ + static Change of(ModifiedFile file) { + + if (!file.isJavaSource()) { + return new OtherFileChange(file.path()); + } + + var withoutExtension = StringUtils.stripFilenameExtension(file.path()); + var startOfMainDir = withoutExtension.indexOf(JavaSourceChange.STANDARD_SOURCE_DIRECTORY); + var startOfTestDir = withoutExtension.indexOf(JavaSourceChange.STANDARD_TEST_SOURCE_DIRECTORY); + + if (startOfTestDir > -1 && (startOfMainDir < 0 || startOfTestDir < startOfMainDir)) { + + var fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( + withoutExtension.substring(startOfTestDir + JavaSourceChange.STANDARD_TEST_SOURCE_DIRECTORY.length() + 1)); - int startOfMainDir = withoutExtension.indexOf(STANDARD_SOURCE_DIRECTORY); - int startOfTestDir = withoutExtension.indexOf(STANDARD_TEST_SOURCE_DIRECTORY); + return new JavaTestSourceChange(fullyQualifiedClassName); + } + + if (startOfMainDir > -1 && (startOfTestDir < 0 || startOfMainDir < startOfTestDir)) { + + var fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( + withoutExtension.substring(startOfMainDir + JavaSourceChange.STANDARD_SOURCE_DIRECTORY.length() + 1)); + + return new JavaSourceChange(fullyQualifiedClassName); + } + + return new JavaSourceChange(ClassUtils.convertResourcePathToClassName(withoutExtension)); + } - if (startOfTestDir > 0 && (startOfMainDir < 0 || startOfTestDir < startOfMainDir)) { - String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( - withoutExtension.substring(startOfTestDir + STANDARD_TEST_SOURCE_DIRECTORY.length() + 1)); + /** + * A change to a source file. + * + * @author Oliver Drotbohm + */ + sealed interface SourceChange extends Change permits JavaSourceChange, JavaTestSourceChange { - return new JavaTestClassChange(fullyQualifiedClassName); - } else if (startOfMainDir > 0 && (startOfTestDir < 0 || startOfMainDir < startOfTestDir)) { - String fullyQualifiedClassName = ClassUtils.convertResourcePathToClassName( - withoutExtension.substring(startOfMainDir + STANDARD_SOURCE_DIRECTORY.length() + 1)); + String fullyQualifiedClassName(); - 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); + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.Change#hasOrigin(java.lang.String) + */ + @Override + default boolean hasOrigin(String nameOrPath) { + return fullyQualifiedClassName().equals(nameOrPath); + } + } + + /** + * A change in a Java source file. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ + record JavaSourceChange(String fullyQualifiedClassName) implements SourceChange { + + private static final String STANDARD_SOURCE_DIRECTORY = "src/main/java"; + private static final String STANDARD_TEST_SOURCE_DIRECTORY = "src/test/java"; + + /* + * (non-Javadoc) + * @see java.lang.Record#toString() + */ + @Override + public final String toString() { + return "☕ " + fullyQualifiedClassName; + } + } + + /** + * A change in a Java test source file. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ + record JavaTestSourceChange(String fullyQualifiedClassName) implements SourceChange {} + + /** + * Some arbitrary file change. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ + record OtherFileChange(String path) implements Change { + + private static final Collection CLASSPATH_RESOURCES = Set.of("src/main/resources", "src/test/resources"); + private static final Collection BUILD_FILES = Set.of("build.gradle", "build.kt", "pom.xml"); + + /** + * Returns whether the change affects a build resource. + */ + public boolean affectsBuildResource() { + return BUILD_FILES.contains(path); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.Change#hasOrigin(java.lang.String) + */ + @Override + public boolean hasOrigin(String nameOrPath) { + return path.equals(nameOrPath); + } + + /** + * Returns whether the change affects a classpath resource (in contrast to other resources). + */ + public boolean isClasspathResource() { + return CLASSPATH_RESOURCES.stream().anyMatch(path::startsWith); + } - return new JavaClassChange(fullyQualifiedClassName); + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.Change.OtherFileChange#toString() + */ + @Override + public final String toString() { + return "📄 " + path; } - } 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()); } } } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionCondition.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionCondition.java new file mode 100644 index 00000000..39be7714 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionCondition.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit; + +import java.lang.StackWalker.StackFrame; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.lang.Nullable; +import org.springframework.modulith.junit.TestExecutionCondition.ConditionContext; + +/** + * JUnit Extension to optimize test execution based on which files have changed in a Spring Modulith project. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ +public class ModulithExecutionCondition implements ExecutionCondition { + + private static final Collection IDE_PACKAGES = Set.of("org.eclipse.jdt", "com.intellij"); + private static final @Nullable TestExecutionCondition CONDITION; + + static { + + var walker = StackWalker.getInstance(); + + CONDITION = walker.walk(ModulithExecutionCondition::hasIdeRoot) ? null : new TestExecutionCondition(); + } + + /* + * (non-Javadoc) + * @see org.junit.jupiter.api.extension.ExecutionCondition#evaluateExecutionCondition(org.junit.jupiter.api.extension.ExtensionContext) + */ + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + + if (CONDITION == null) { + return ConditionEvaluationResult.enabled("Optimization disabled in IDE execution!"); + } + + if (context.getTestMethod().isPresent()) { + return ConditionEvaluationResult.enabled("Not filtering on a per method basis!"); + } + + return context.getTestClass().map(testClass -> { + + var store = new StateStore(context); + var changes = store.getChanges(); + + return changes.isEmpty() + ? ConditionEvaluationResult.enabled("No changes detected!") + : CONDITION.evaluate(new ConditionContext(testClass, changes)); + + }).orElseGet(() -> ConditionEvaluationResult.enabled("Not a test class context!")); + } + + /** + * Returns whether the class is loaded in an IDE context. + */ + private static boolean hasIdeRoot(Stream frames) { + + var all = frames.toList(); + var rootClassName = all.get(all.size() - 1).getClassName(); + + return IDE_PACKAGES.stream().anyMatch(rootClassName::startsWith); + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java deleted file mode 100644 index 23912c31..00000000 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/ModulithExecutionExtension.java +++ /dev/null @@ -1,122 +0,0 @@ -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> 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> 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 optionalApplicationModule = applicationModules.getModuleForPackage(packageName); - if (optionalApplicationModule.isPresent()) { - - Set 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 getAllDependentClasses(ApplicationModule applicationModule, - ApplicationModules applicationModules) { - - Set 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 modules) { - - Set 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); - } - } - } - -} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java index 84699dba..283bab6a 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/StateStore.java @@ -1,71 +1,84 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ 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.junit.jupiter.api.extension.ExtensionContext.Store; 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; +import org.springframework.util.Assert; +/** + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ class StateStore { + private static final Logger log = LoggerFactory.getLogger(StateStore.class); - private final ExtensionContext.Store store; + private final Store store; + /** + * Creates a new {@link StateStore} for the given {@link ExtensionContext}. + * + * @param context must not be {@literal null}. + */ StateStore(ExtensionContext context) { - store = context.getRoot().getStore(Namespace.create(ModulithExecutionExtension.class)); + + Assert.notNull(context, "ExtensionContext must not be null!"); + + this.store = context.getRoot().getStore(Namespace.create(ModulithExecutionCondition.class)); } - Set> getChangedClasses() { - // noinspection unchecked - return (Set>) store.getOrComputeIfAbsent("changed-files", s -> { + /** + * Returns all changes made to the project. + * + * @return + */ + Changes getChanges() { + + return (Changes) store.getOrComputeIfAbsent("changed-files", s -> { + + // Lookup configuration var environment = new StandardEnvironment(); ConfigDataEnvironmentPostProcessor.applyTo(environment); - var detector = FileModificationDetector.loadFileModificationDetector(environment); - try { - Set modifiedFiles = detector.getModifiedFiles(environment); - Set 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(); + if (Boolean.TRUE == environment.getProperty("spring.modulith.test.skip-optimizations", Boolean.class)) { + return Changes.NONE; } - }); - } - private static Set> toChangedClasses(Set changes) { - Set> changedClasses = new HashSet<>(); - for (Change change : changes) { - if (change instanceof OtherFileChange) { - continue; - } + // Determine detector + var detector = FileModificationDetector.getDetector(environment); + var result = Changes.of(detector.getModifiedFiles()); - 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()); - } + if (log.isInfoEnabled()) { - try { - Class aClass = ClassUtils.forName(className, null); - changedClasses.add(aClass); - } catch (ClassNotFoundException e) { - log.trace("ModulithExecutionExtension: Unable to find class \"{}\"", className); + log.trace("Detected changes:"); + log.trace("-----------------"); + + result.forEach(it -> log.info(it.toString())); } - } - return changedClasses; + + // Obtain changes + return Changes.of(detector.getModifiedFiles()); + }); } } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/TestExecutionCondition.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/TestExecutionCondition.java new file mode 100644 index 00000000..be4be0a8 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/TestExecutionCondition.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.AnnotatedClassFinder; +import org.springframework.modulith.core.ApplicationModule; +import org.springframework.modulith.core.ApplicationModuleDependencies; +import org.springframework.modulith.core.ApplicationModules; +import org.springframework.util.ClassUtils; + +/** + * Primary condition implementation that decides whether to run a test based on an {@link ConditionContext}. Simple + * facade for testability without needing to instantiate more involved JUnit concepts. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + * @see ModulithExecutionCondition + */ +class TestExecutionCondition { + + private static final Logger log = LoggerFactory.getLogger(TestExecutionCondition.class); + private static final AnnotatedClassFinder SPA_CLASS_FINDER = new AnnotatedClassFinder(SpringBootConfiguration.class); + + private final Map dependencies = new HashMap<>(); + + ConditionEvaluationResult evaluate(ConditionContext context) { + + var changes = context.changes(); + + if (changes.hasClasspathResourceChange()) { + return enabled("Some classpath resource changed, running all tests."); + } + + if (changes.hasBuildResourceChanges()) { + return enabled("Build resource changed, running all tests."); + } + + if (!changes.hasClassChanges()) { + return enabled("No source file changes detected."); + } + + var changedClasses = changes.getChangedClasses(); + + log.trace("Found following changed classes {}", changedClasses); + + var testClass = context.testClass(); + + if (changedClasses.contains(testClass.getName())) { + return enabled("Change in test class detected, executing test."); + } + + var mainClass = SPA_CLASS_FINDER.findFromClass(testClass); + + if (mainClass == null) {// TODO:: Try with @ApplicationModuleTest -> main class + return enabled("Unable to locate SpringBootApplication Class"); + } + + var modules = ApplicationModules.of(mainClass); + var packageName = ClassUtils.getPackageName(testClass); + + return modules.getModuleForPackage(packageName).map(it -> { + + if (it.isRootModule()) { + return enabled("Always executing tests in root modules."); + } + + var dependencies = this.dependencies.computeIfAbsent(it, m -> m.getAllDependencies(modules)); + + for (String changedClass : changedClasses) { + + if (it.contains(changedClass)) { + return enabled("Changes detected in module %s, executing test.".formatted(it.getName())); + } + + var dependency = dependencies.getModuleByType(changedClass); + + if (dependency != null) { + return enabled("Changes detected in dependent module %s, executing test.".formatted(dependency.getName())); + } + } + + return disabled("Test residing in module %s not affected by changes!".formatted(it.getName())); + + }).orElseGet(() -> enabled("Test in package %s does not reside in any module!".formatted(packageName))); + } + + private static ConditionEvaluationResult enabled(String message) { + return result(ConditionEvaluationResult::enabled, "▶️ " + message); + } + + private static ConditionEvaluationResult disabled(String message) { + return result(ConditionEvaluationResult::disabled, "⏸️ ️" + message); + } + + private static ConditionEvaluationResult result(Function creator, String message) { + + log.info(message); + + return creator.apply(message); + } + + record ConditionContext(Class testClass, Changes changes) {} +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java index ada796f0..f6e32ff7 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/FileModificationDetector.java @@ -1,49 +1,104 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.modulith.junit.diff; -import static org.springframework.modulith.junit.ModulithExecutionExtension.*; +import java.util.stream.Stream; -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.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; +/** + * SPI to plug different strategies of how to find the files currently modified in a project. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ public interface FileModificationDetector { - Logger log = LoggerFactory.getLogger(FileModificationDetector.class); - String CLASS_FILE_SUFFIX = ".java"; - String PACKAGE_PREFIX = "src.main.java"; + public static final String CONFIG_PROPERTY_PREFIX = "spring.modulith.test"; + static final Logger log = LoggerFactory.getLogger(FileModificationDetector.class); + + /** + * Returns all {@link ModifiedFile}s detected. + * + * @return will never be {@literal null}. + */ + Stream getModifiedFiles(); - Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) - throws IOException, GitAPIException; + /** + * Returns the {@link FileModificationDetector} to be used. + * + * @param propertyResolver must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static FileModificationDetector getDetector(PropertyResolver propertyResolver) { - static FileModificationDetector loadFileModificationDetector(@NonNull PropertyResolver propertyResolver) { - var detectorClassName = propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".file-modification-detector"); + Assert.notNull(propertyResolver, "PropertyResolver must not be null!"); + + var detectorSelector = 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); + if (!StringUtils.hasText(detectorSelector)) { + return getDefaultDetector(referenceCommit); + } + + return switch (detectorSelector) { + + case "uncommitted-changes" -> UncommittedChangesDetector.INSTANCE; + case "reference-commit" -> new ReferenceCommitDetector(referenceCommit); + case "default" -> getDefaultDetector(referenceCommit); + default -> { + + try { - } catch (ClassNotFoundException | LinkageError o_O) { - throw new IllegalStateException(o_O); + var detectorType = ClassUtils.forName(detectorSelector, FileModificationDetector.class.getClassLoader()); + + log.info("Found request via property for file modification detector '{}'", detectorSelector); + + yield BeanUtils.instantiateClass(detectorType, 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(); + }; + } + + /** + * Returns a {@link ReferenceCommitDetector} if the given reference commit is not empty. + * + * @param referenceCommit can be {@literal null} or empty. + * @return will never be {@literal null}. + */ + private static FileModificationDetector getDefaultDetector(@Nullable String referenceCommit) { + + if (StringUtils.hasText(referenceCommit)) { + return new ReferenceCommitDetector(referenceCommit); } - log.info("Using default file modification detector '{}'", UnpushedCommitsDetector.class.getName()); - return new UnpushedCommitsDetector(); + log.info("Using default file modification detector (uncommitted and unpushed changes)."); + + return () -> Stream.of(UncommittedChangesDetector.INSTANCE, UnpushedCommitsDetector.INSTANCE) + .flatMap(it -> it.getModifiedFiles()); } } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java index 5eb0c588..433a3a5a 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/JGitUtil.java @@ -1,61 +1,84 @@ package org.springframework.modulith.junit.diff; -import java.io.IOException; +import java.util.function.Supplier; 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; +import org.springframework.util.function.ThrowingFunction; +import org.springframework.util.function.ThrowingSupplier; /** * Utility to contain re-used JGit operations. For internal use only. */ -final class JGitUtil { - private JGitUtil() {} +interface JGitUtil { - static Stream convertDiffEntriesToFileChanges(Stream diffEntries) { - return diffEntries.flatMap( - entry -> Stream.of(new ModifiedFilePath(entry.getNewPath()), new ModifiedFilePath(entry.getOldPath()))) - .filter(change -> !change.path().equals("/dev/null")); + static T withTry(ThrowingSupplier supplier) { + + try { + return supplier.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } } - static Repository buildRepository() throws IOException { - return new FileRepositoryBuilder().findGitDir().build(); + static T withTry(Supplier closable, ThrowingFunction supplier) { + + try (A a = closable.get()) { + return supplier.apply(a); + } catch (Exception e) { + throw new RuntimeException(e); + } } - static Stream diffRefs(Repository repository, String oldRef, String newRef) throws IOException { + static T withRepository(ThrowingFunction function) { + return withTry(JGitUtil::buildRepository, function); + } + + static Stream toModifiedFiles(Repository repository, String oldRef, String newRef) { + 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(); + var oldTreeParser = prepareTreeParser(repository, oldRef); + var newTreeParser = prepareTreeParser(repository, newRef); + + return git.diff() + .setOldTree(oldTreeParser) + .setNewTree(newTreeParser) + .call().stream() + .flatMap(entry -> ModifiedFile.of(entry.getNewPath(), entry.getOldPath())) + .distinct() + .filter(change -> !change.path().equals("/dev/null")); + } catch (GitAPIException e) { - throw new IOException("Unable to find diff between refs '%s' and '%s'".formatted(oldRef, newRef), e); + throw new RuntimeException("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); + private static Repository buildRepository() { + return withTry(() -> new FileRepositoryBuilder().findGitDir().build()); + } + + private static AbstractTreeIterator prepareTreeParser(Repository repository, String ref) { + + return withTry(() -> new RevWalk(repository), walk -> { - try (RevWalk walk = new RevWalk(repository)) { - RevCommit commit = walk.parseCommit(commitId); - RevTree tree = walk.parseTree(commit.getTree().getId()); + var commitId = repository.resolve(ref); + var commit = walk.parseCommit(commitId); + var tree = walk.parseTree(commit.getTree().getId()); + var treeParser = new CanonicalTreeParser(); - CanonicalTreeParser treeParser = new CanonicalTreeParser(); try (ObjectReader reader = repository.newObjectReader()) { treeParser.reset(reader, tree.getId()); } return treeParser; - } + }); } } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFile.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFile.java new file mode 100644 index 00000000..d758b580 --- /dev/null +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFile.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit.diff; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.springframework.util.StringUtils; + +/** + * A modified file. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ +public record ModifiedFile(String path) { + + /** + * Returns whether the modified file is a Java source file. + */ + public boolean isJavaSource() { + return "java".equalsIgnoreCase(StringUtils.getFilenameExtension(path)); + } + + public static Stream of(String... paths) { + return Arrays.stream(paths).map(ModifiedFile::new); + } +} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java deleted file mode 100644 index 5ff99fc0..00000000 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ModifiedFilePath.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.springframework.modulith.junit.diff; - -public record ModifiedFilePath(String path) {} diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java index 0f28f7fe..dbd2a071 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/ReferenceCommitDetector.java @@ -1,48 +1,77 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.modulith.junit.diff; -import static org.springframework.modulith.junit.ModulithExecutionExtension.*; +import static org.springframework.modulith.junit.diff.JGitUtil.*; -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; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * Implementation to get changes between HEAD and a complete or abbreviated SHA-1 or other revision, like * HEAD~2. See {@link org.eclipse.jgit.lib.Repository#resolve(String)} for more information. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm */ -public class ReferenceCommitDetector implements FileModificationDetector { +class ReferenceCommitDetector implements FileModificationDetector { + private static final Logger log = LoggerFactory.getLogger(ReferenceCommitDetector.class); + private static final String REFERENCE_COMMIT_PROPERTY = CONFIG_PROPERTY_PREFIX + ".reference-commit"; - @Override - public @NonNull Set 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 diffs = JGitUtil.diffRefs(repo, compareTo, localBranch); - - return JGitUtil.convertDiffEntriesToFileChanges(diffs).collect(Collectors.toSet()); + private final String referenceCommit; + + /** + * Creates a new {@link ReferenceCommitDetector} for the given reference commit. + * + * @param referenceCommit can be {@literal null}. + */ + ReferenceCommitDetector(@Nullable String referenceCommit) { + + if (StringUtils.hasText(referenceCommit)) { + log.info("Comparing to git commit {}", referenceCommit); + this.referenceCommit = referenceCommit; + } else { + log.warn("No reference-commit configured, comparing to HEAD."); + this.referenceCommit = "HEAD"; } } + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.FileModificationDetector#getModifiedFiles() + */ + @Override + public Stream getModifiedFiles() { + + return withRepository(repo -> { + + var localBranch = repo.getFullBranch(); + return toModifiedFiles(repo, referenceCommit, localBranch); + }); + } + + @Nullable public static String getReferenceCommitProperty(PropertyResolver propertyResolver) { - return propertyResolver.getProperty(CONFIG_PROPERTY_PREFIX + ".reference-commit"); + return propertyResolver.getProperty(REFERENCE_COMMIT_PROPERTY); } } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java index d92f0bf4..e4e70ef5 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UncommittedChangesDetector.java @@ -1,17 +1,26 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.modulith.junit.diff; -import java.io.IOException; -import java.util.Set; -import java.util.stream.Collectors; +import static org.springframework.modulith.junit.diff.JGitUtil.*; + 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; @@ -19,27 +28,30 @@ * Implementation to get latest local file changes. * * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm */ -public class UncommittedChangesDetector implements FileModificationDetector { +enum UncommittedChangesDetector implements FileModificationDetector { - @Override - public @NonNull Set getModifiedFiles(@NonNull PropertyResolver propertyResolver) - throws IOException { + INSTANCE; - try (var repo = new FileRepositoryBuilder().findGitDir().build()) { - return findUncommittedChanges(repo).collect(Collectors.toSet()); - } + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.FileModificationDetector#getModifiedFiles() + */ + @Override + public Stream getModifiedFiles() { + return withRepository(UncommittedChangesDetector::findUncommittedChanges); } - private static Stream findUncommittedChanges(Repository repository) throws IOException { - try (Git git = new Git(repository)) { - Status status = git.status().call(); + private static Stream findUncommittedChanges(Repository repository) { + + return withTry(() -> new Git(repository), git -> { + + var 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); - } + .map(ModifiedFile::new); + }); } - } diff --git a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java index 0995e9e5..3683786c 100644 --- a/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java +++ b/spring-modulith-junit/src/main/java/org/springframework/modulith/junit/diff/UnpushedCommitsDetector.java @@ -1,15 +1,25 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.springframework.modulith.junit.diff; -import java.io.IOException; -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; +import static org.springframework.modulith.junit.diff.JGitUtil.*; + 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; /** *

@@ -17,25 +27,30 @@ *

* To be precise, this finds the diff between the local HEAD and its tracking branch and the uncommitted and untracked * changes. Note: This will not fetch from the remote first! + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm */ -public class UnpushedCommitsDetector implements FileModificationDetector { +enum UnpushedCommitsDetector implements FileModificationDetector { + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.modulith.junit.FileModificationDetector#getModifiedFiles() + */ @Override - public @NonNull Set 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 diff = localBranch != null && trackingBranch != null - ? JGitUtil.diffRefs(repo, localBranch, trackingBranch) - : Stream.empty(); + public Stream getModifiedFiles() { - HashSet result = new HashSet<>(); - result.addAll(new UncommittedChangesDetector().getModifiedFiles(propertyResolver)); - result.addAll(JGitUtil.convertDiffEntriesToFileChanges(diff).collect(Collectors.toSet())); - return result; - } - } + return withRepository(repo -> { + + var localBranch = repo.getFullBranch(); + var trackingBranch = new BranchConfig(repo.getConfig(), repo.getBranch()).getTrackingBranch(); + return localBranch != null && trackingBranch != null + ? toModifiedFiles(repo, localBranch, trackingBranch) + : Stream.empty(); + }); + } } diff --git a/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json deleted file mode 100644 index d96288d6..00000000 --- a/spring-modulith-junit/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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": [] -} diff --git a/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension index 924a6a1b..917af696 100644 --- a/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension +++ b/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -1 +1 @@ -org.springframework.modulith.junit.ModulithExecutionExtension +org.springframework.modulith.junit.ModulithExecutionCondition diff --git a/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector b/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector deleted file mode 100644 index b96b4406..00000000 --- a/spring-modulith-junit/src/main/resources/META-INF/services/org.springframework.modulith.FileModificationDetector +++ /dev/null @@ -1,3 +0,0 @@ -org.springframework.modulith.git.UncommittedChangesDetector -org.springframework.modulith.git.UnpushedGitChangesDetector -org.springframework.modulith.git.DiffDetector diff --git a/spring-modulith-junit/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-modulith-junit/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..154dec4c --- /dev/null +++ b/spring-modulith-junit/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,47 @@ +{ + "groups": [ + { + "name": "spring.modulith.test", + "description": "Properties configuring the execution of modulith-specific tests." + } + ], + "properties": [ + { + "name": "spring.modulith.test.file-modification-detector", + "type": "java.lang.String", + "description": "A strategy to determine the list of changed files to consider for test execution. The default will consider a potentially configured spring.modulith.test.reference-commit and fall back to both uncommited changes and the ones made in commits on top of the current tracking branch." + }, + { + "name": "spring.modulith.test.reference-commit", + "type": "java.lang.String", + "description": "The hash of the commit to track back changes to. Usually set in a CI environment." + } + ], + "hints": [ + { + "name": "spring.modulith.test.file-modification-detector", + "values": [ + { + "value": "uncommitted-changes", + "description" : "Considers all uncommitted changes." + }, + { + "value": "default", + "description" : "Consider both uncommited changes and the ones made in commits on top of the current tracking branch." + }, + { + "value": "reference-commit", + "description" : "Considers changes between the current head and a given reference commit set in spring.modulith.test.reference-commit." + } + ], + "providers": [ + { + "name": "class-reference", + "parameters": { + "target": "org.springframework.modulith.junit.diff.FileModificationDetector" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/spring-modulith-junit/src/test/java/example/a/package-info.java b/spring-modulith-junit/src/test/java/example/a/package-info.java new file mode 100644 index 00000000..be87a764 --- /dev/null +++ b/spring-modulith-junit/src/test/java/example/a/package-info.java @@ -0,0 +1 @@ +package example.a; diff --git a/spring-modulith-junit/src/test/java/example/b/package-info.java b/spring-modulith-junit/src/test/java/example/b/package-info.java new file mode 100644 index 00000000..58d83ab5 --- /dev/null +++ b/spring-modulith-junit/src/test/java/example/b/package-info.java @@ -0,0 +1 @@ +package example.b; diff --git a/spring-modulith-junit/src/test/java/example/package-info.java b/spring-modulith-junit/src/test/java/example/package-info.java new file mode 100644 index 00000000..e83ec38c --- /dev/null +++ b/spring-modulith-junit/src/test/java/example/package-info.java @@ -0,0 +1 @@ +package example; diff --git a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java deleted file mode 100644 index 0531b634..00000000 --- a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangeTest.java +++ /dev/null @@ -1,33 +0,0 @@ -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 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 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")); - } -} diff --git a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangesUnitTests.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangesUnitTests.java new file mode 100644 index 00000000..644f3833 --- /dev/null +++ b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/ChangesUnitTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.modulith.junit.Changes.Change.JavaSourceChange; +import org.springframework.modulith.junit.Changes.Change.JavaTestSourceChange; +import org.springframework.modulith.junit.Changes.Change.OtherFileChange; +import org.springframework.modulith.junit.diff.ModifiedFile; + +/** + * Unit tests for {@link Changes}. + * + * @author Lukas Dohmen + * @author David Bilge + * @author Oliver Drotbohm + */ +class ChangesUnitTests { + + @TestFactory // GH-31 + Stream detectsClasspathFileChange() { + + var files = Stream.of("src/main/resources/some.txt", "src/test/resources/some.txt"); + + return DynamicTest.stream(files, it -> it + " is considered classpath resource", it -> { + assertThat(new OtherFileChange(it).isClasspathResource()).isTrue(); + }); + } + + @TestFactory // GH-31 + Stream detectsNonClasspathFileChange() { + + var files = Stream.of("pom.xml", "build.gradle", "build.kt"); + + return DynamicTest.stream(files, it -> it + " is considered build resource", it -> { + + var change = new OtherFileChange(it); + + assertThat(change.isClasspathResource()).isFalse(); + assertThat(change.affectsBuildResource()).isTrue(); + }); + } + + @Test // GH-31 + void shouldInterpredModifiedFilePathsCorrectly() { + + // given + var modifiedFilePaths = Stream.of( + "src/main/java/org/springframework/modulith/junit/Changes.java", + "src/test/java/org/springframework/modulith/ChangesTest.java", + "src/main/resources/META-INF/additional-spring-configuration-metadata.json") + .map(ModifiedFile::new); + + // when + var result = Changes.of(modifiedFilePaths); + + // then + assertThat(result.hasClasspathResourceChange()).isTrue(); + assertThat(result).containsExactlyInAnyOrder( + new JavaSourceChange("org.springframework.modulith.junit.Changes"), + new JavaTestSourceChange("org.springframework.modulith.ChangesTest"), + new OtherFileChange("src/main/resources/META-INF/additional-spring-configuration-metadata.json")); + } +} diff --git a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/FileModificationDetectorUnitTests.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/FileModificationDetectorUnitTests.java new file mode 100644 index 00000000..8d8f3555 --- /dev/null +++ b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/FileModificationDetectorUnitTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit.diff; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; + +/** + * Unit tests for {@link FileModificationDetector}. + * + * @author Oliver Drotbohm + */ +class FileModificationDetectorUnitTests { + + @Test // GH-31 + void usesReferenceCommitDetectionIfHashConfigured() { + assertDetector(ReferenceCommitDetector.class, null, "HEAD^"); + } + + @Test // GH-31 + void usesReferenceCommitDetectionIfConfiguredExplicitly() { + assertDetector(ReferenceCommitDetector.class, "reference-commit", null); + } + + @Test // GH-31 + void usesUncommittedChangesIfConfiguredExplicitly() { + assertDetector(UncommittedChangesDetector.class, "uncommitted-changes", null); + } + + @Test // GH-31 + void registersCustomDetectorByType() { + + var customDetector = CustomFileModificationDetector.class; + + assertDetector(customDetector, customDetector.getName(), null); + } + + @Test // GH-31 + void selectingDefaultExplicitlyUsesDefault() { + + var explicitDetector = FileModificationDetector.getDetector(setupEnvironment("default", null)); + + assertThat(FileModificationDetector.getDetector(setupEnvironment(null, null))) + .isEqualTo(explicitDetector); + } + + @Test // GH-31 + void rejectsInvalidDetectorName() { + + assertThatIllegalStateException().isThrownBy(() -> { + FileModificationDetector.getDetector(setupEnvironment("some.Garbage", null)); + }); + } + + private static void assertDetector(Class expected, String detector, String referenceCommit) { + + var environment = setupEnvironment(detector, referenceCommit); + + assertThat(FileModificationDetector.getDetector(environment)).isInstanceOf(expected); + } + + private static Environment setupEnvironment(String detector, String referenceCommit) { + + var environment = new MockEnvironment(); + + if (detector != null) { + environment.setProperty("spring.modulith.test.file-modification-detector", detector); + } + + if (referenceCommit != null) { + environment.setProperty("spring.modulith.test.reference-commit", referenceCommit); + } + + return environment; + } + + static class CustomFileModificationDetector implements FileModificationDetector { + + @Override + public Stream getModifiedFiles() { + return Stream.empty(); + } + } +} diff --git a/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java new file mode 100644 index 00000000..1c43a480 --- /dev/null +++ b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.junit.diff; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ReferenceCommitDetector}. + * + * @author Oliver Drotbohm + */ +class ReferenceCommitDetectorUnitTests { + + @Test // GH-31 + void detectsChangesOfHead() { + + var detector = new ReferenceCommitDetector(null); + + assertThat(detector.getModifiedFiles()).isEmpty(); + } +}