From 48fefc10151c6e3fca1c4a21f2387cdc1748c86c 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 | 78 +++-- .../modulith/junit/diff/JGitUtil.java | 73 +++-- .../modulith/junit/diff/ModifiedFile.java | 42 +++ .../modulith/junit/diff/ModifiedFilePath.java | 3 - .../junit/diff/ReferenceCommitDetector.java | 79 +++-- .../diff/UncommittedChangesDetector.java | 58 ++-- .../junit/diff/UnpushedCommitsDetector.java | 61 ++-- .../org.junit.jupiter.api.extension.Extension | 2 +- ...ramework.modulith.FileModificationDetector | 3 - .../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 +++++ .../ReferenceCommitDetectorUnitTests.java | 45 +++ 29 files changed, 1058 insertions(+), 369 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/services/org.springframework.modulith.FileModificationDetector 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/ReferenceCommitDetectorUnitTests.java diff --git a/pom.xml b/pom.xml index b7da11ce6..6b0315337 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 4caa1c02a..5aa0b9ef4 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 f9499ab3a..4e6816b91 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 170d5b706..a45c42869 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 7b874c8d7..b3acb0340 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 682d2e9ca..9db43bfdf 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 000000000..9403a44fc --- /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 c96392ca2..721091cc6 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 821bdc761..000000000 --- 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 a4dab76f5..d09b68216 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 000000000..39be7714b --- /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 23912c310..000000000 --- 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 84699dba2..283bab6a2 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 000000000..be4be0a85 --- /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 ada796f05..2b6992f18 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,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.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.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) { + + Assert.notNull(propertyResolver, "PropertyResolver must not be null!"); - 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()); + + var strategyType = ClassUtils.forName(detectorClassName, FileModificationDetector.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(); + var referenceCommit = ReferenceCommitDetector.getReferenceCommitProperty(propertyResolver); + + if (StringUtils.hasText(referenceCommit)) { + return new ReferenceCommitDetector(referenceCommit); + } + + 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 5eb0c5881..433a3a5ab 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 000000000..d758b580c --- /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 5ff99fc06..000000000 --- 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 0f28f7feb..ed5926309 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,73 @@ +/* + * 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; /** * 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; + + /** + * @param referenceCommit + */ + ReferenceCommitDetector(@Nullable String referenceCommit) { + + if (referenceCommit == null || referenceCommit.isEmpty()) { + log.warn("No reference-commit configured, comparing to HEAD."); + this.referenceCommit = "HEAD"; + } else { + log.info("Comparing to git commit {}", referenceCommit); + this.referenceCommit = referenceCommit; } } + /* + * (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); + }); + } + 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 d92f0bf48..e4e70ef5d 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 0995e9e53..3683786cc 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/services/org.junit.jupiter.api.extension.Extension b/spring-modulith-junit/src/main/resources/META-INF/services/org.junit.jupiter.api.extension.Extension index 924a6a1bd..917af696e 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 b96b44066..000000000 --- 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/test/java/example/a/package-info.java b/spring-modulith-junit/src/test/java/example/a/package-info.java new file mode 100644 index 000000000..be87a7644 --- /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 000000000..58d83ab5a --- /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 000000000..e83ec38c2 --- /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 0531b6342..000000000 --- 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 000000000..644f3833b --- /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/ReferenceCommitDetectorUnitTests.java b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java new file mode 100644 index 000000000..70f5e65e0 --- /dev/null +++ b/spring-modulith-junit/src/test/java/org/springframework/modulith/junit/diff/ReferenceCommitDetectorUnitTests.java @@ -0,0 +1,45 @@ +/* + * 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 + */ +public class ReferenceCommitDetectorUnitTests { + + @Test + void detectsChangesOfHead() { + + var detector = new ReferenceCommitDetector(null); + + assertThat(detector.getModifiedFiles()).isEmpty(); + } + + @Test + void detectsChangesFromEarlierCommit() { + + var first = new ReferenceCommitDetector(null).getModifiedFiles().toList(); + var second = new ReferenceCommitDetector("HEAD^^^^^").getModifiedFiles().toList(); + + assertThat(second).isNotEqualTo(first); + } +}