GH-1 - Initial port of Moduliths project.

Basically the state of commit c7cf939 of https://github.com/moduliths/moduliths for further development under the Spring umbrella.
This commit is contained in:
Oliver Drotbohm
2022-07-06 12:58:50 +02:00
commit 6c444769d7
236 changed files with 17771 additions and 0 deletions

60
moduliths-test/pom.xml Normal file
View File

@@ -0,0 +1,60 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.moduliths</groupId>
<artifactId>moduliths</artifactId>
<version>1.4.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<name>Moduliths - Test</name>
<artifactId>moduliths-test</artifactId>
<properties>
<module.name>org.moduliths.test</module.name>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>moduliths-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2020 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.moduliths.test;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.data.domain.DomainEvents;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.ReflectionUtils.MethodCallback;
/**
* Test utilities to work with aggregates.
*
* @author Oliver Drotbohm
*/
public class AggregateTestUtils {
private static Map<Class<?>, Optional<Method>> CACHE = new ConcurrentHashMap<>();
/**
* Extracts all domain events from the given aggregate that uses Spring Data's {@link DomainEvents} annotation to
* expose them.
*
* @param aggregate must not be {@literal null}.
* @return {@link PublishedEvents} for all events contained in the given aggregate, will never be {@literal null}.
*/
public static PublishedEvents eventsOf(Object aggregate) {
Collection<?> events = CACHE.computeIfAbsent(aggregate.getClass(), AggregateTestUtils::findAnnotatedMethod)
.map(it -> ReflectionUtils.invokeMethod(it, aggregate)) //
.map(Collection.class::cast) //
.orElseGet(Collections::emptyList);
return PublishedEvents.of(events);
}
private static Optional<Method> findAnnotatedMethod(Class<?> type) {
DomainEventsMethodFinder finder = new DomainEventsMethodFinder();
ReflectionUtils.doWithMethods(type, finder);
return Optional.ofNullable(finder.method);
}
/**
* {@link MethodCallback} to find a method annotated with {@link DomainEvents}.
*
* @author Oliver Drotbohm
*/
private static class DomainEventsMethodFinder implements MethodCallback {
Method method;
/*
* (non-Javadoc)
* @see org.springframework.util.ReflectionUtils.MethodCallback#doWith(java.lang.reflect.Method)
*/
@Override
public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException {
if (this.method != null) {
return;
}
if (method.isAnnotationPresent(DomainEvents.class)) {
this.method = method;
ReflectionUtils.makeAccessible(method);
}
}
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2019-2020 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.moduliths.test;
import lombok.RequiredArgsConstructor;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.util.Assert;
/**
* Default implementation of {@link PublishedEvents}.
*
* @author Oliver Drotbohm
*/
class DefaultPublishedEvents implements PublishedEvents, ApplicationListener<ApplicationEvent> {
private final List<Object> events;
/**
* Creates a new, empty {@link DefaultPublishedEvents} instance.
*/
DefaultPublishedEvents() {
this(Collections.emptyList());
}
/**
* Creates a new {@link DefaultPublishedEvents} instance with the given events.
*
* @param events must not be {@literal null}.
*/
DefaultPublishedEvents(Collection<? extends Object> events) {
Assert.notNull(events, "Events must not be null!");
this.events = new ArrayList<>(events);
}
/*
* (non-Javadoc)
* @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent)
*/
@Override
public void onApplicationEvent(ApplicationEvent event) {
this.events.add(unwrapPayloadEvent(event));
}
/*
* (non-Javadoc)
* @see org.moduliths.test.PublishedEvents#ofType(java.lang.Class)
*/
@Override
public <T> TypedPublishedEvents<T> ofType(Class<T> type) {
return SimpleTypedPublishedEvents.of(events.stream()//
.filter(type::isInstance) //
.map(type::cast));
}
private static Object unwrapPayloadEvent(Object source) {
return PayloadApplicationEvent.class.isInstance(source) //
? ((PayloadApplicationEvent<?>) source).getPayload() //
: source;
}
@RequiredArgsConstructor(staticName = "of")
private static class SimpleTypedPublishedEvents<T> implements TypedPublishedEvents<T> {
private final List<T> events;
private static <T> SimpleTypedPublishedEvents<T> of(Stream<T> stream) {
return new SimpleTypedPublishedEvents<>(stream.collect(Collectors.toList()));
}
/*
* (non-Javadoc)
* @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#ofSubType(java.lang.Class)
*/
@Override
public <S extends T> TypedPublishedEvents<S> ofSubType(Class<S> subType) {
return SimpleTypedPublishedEvents.of(getFilteredEvents(subType::isInstance) //
.map(subType::cast));
}
/*
* (non-Javadoc)
* @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#matching(java.util.function.Predicate)
*/
@Override
public TypedPublishedEvents<T> matching(Predicate<? super T> predicate) {
return SimpleTypedPublishedEvents.of(getFilteredEvents(predicate));
}
/*
* (non-Javadoc)
* @see org.moduliths.test.PublishedEvents.TypedPublishedEvents#matchingMapped(java.util.function.Function, java.util.function.Predicate)
*/
@Override
public <S> TypedPublishedEvents<T> matchingMapped(Function<T, S> mapper, Predicate<? super S> predicate) {
return SimpleTypedPublishedEvents.of(events.stream().flatMap(it -> {
S mapped = mapper.apply(it);
return predicate.test(mapped) ? Stream.of(it) : Stream.empty();
}));
}
/**
* Returns a {@link Stream} of events filtered by the given {@link Predicate}.
*
* @param predicate must not be {@literal null}.
* @return
*/
private Stream<T> getFilteredEvents(Predicate<? super T> predicate) {
return events.stream().filter(predicate);
}
/*
* (non-Javadoc)
* @see java.lang.Iterable#iterator()
*/
@Override
public Iterator<T> iterator() {
return events.iterator();
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return events.toString();
}
}
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2018 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
*
* http://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.moduliths.test;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.moduliths.model.Module;
import org.moduliths.model.Modules;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.test.context.ContextConfigurationAttributes;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactory;
import org.springframework.test.context.MergedContextConfiguration;
/**
* @author Oliver Gierke
*/
class ModuleContextCustomizerFactory implements ContextCustomizerFactory {
/*
* (non-Javadoc)
* @see org.springframework.test.context.ContextCustomizerFactory#createContextCustomizer(java.lang.Class, java.util.List)
*/
@Override
public ContextCustomizer createContextCustomizer(Class<?> testClass,
List<ContextConfigurationAttributes> configAttributes) {
ModuleTest moduleTest = AnnotatedElementUtils.getMergedAnnotation(testClass, ModuleTest.class);
return moduleTest == null ? null : new ModuleContextCustomizer(testClass);
}
@Slf4j
@EqualsAndHashCode
static class ModuleContextCustomizer implements ContextCustomizer {
private static final String BEAN_NAME = ModuleTestExecution.class.getName();
private final Supplier<ModuleTestExecution> execution;
private ModuleContextCustomizer(Class<?> testClass) {
this.execution = ModuleTestExecution.of(testClass);
}
/*
* (non-Javadoc)
* @see org.springframework.test.context.ContextCustomizer#customizeContext(org.springframework.context.ConfigurableApplicationContext, org.springframework.test.context.MergedContextConfiguration)
*/
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
ModuleTestExecution testExecution = execution.get();
logModules(testExecution);
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
beanFactory.registerSingleton(BEAN_NAME, testExecution);
DefaultPublishedEvents events = new DefaultPublishedEvents();
beanFactory.registerSingleton(events.getClass().getName(), events);
context.addApplicationListener(events);
}
private static void logModules(ModuleTestExecution execution) {
Module module = execution.getModule();
Modules modules = execution.getModules();
String moduleName = module.getDisplayName();
String bootstrapMode = execution.getBootstrapMode().name();
String message = String.format("Bootstrapping @ModuleTest for %s in mode %s (%s)…", moduleName, bootstrapMode,
modules.getModulithSource());
LOG.info(message);
LOG.info(getSeparator("=", message));
Arrays.stream(module.toString(modules).split("\n")).forEach(LOG::info);
List<Module> extraIncludes = execution.getExtraIncludes();
if (!extraIncludes.isEmpty()) {
logHeadline("Extra includes:", message);
extraIncludes.forEach(it -> LOG.info("> ".concat(it.getName())));
}
Set<Module> sharedModules = modules.getSharedModules();
if (!sharedModules.isEmpty()) {
logHeadline("Shared modules:", message);
sharedModules.forEach(it -> LOG.info("> ".concat(it.getName())));
}
List<Module> dependencies = execution.getDependencies();
if (!dependencies.isEmpty() || !sharedModules.isEmpty()) {
logHeadline("Included dependencies:", message);
Stream<Module> dependenciesPlusMissingSharedOnes = //
Stream.concat(dependencies.stream(), sharedModules.stream() //
.filter(it -> !dependencies.contains(it)));
dependenciesPlusMissingSharedOnes //
.map(it -> it.toString(modules)) //
.forEach(it -> {
Arrays.stream(it.split("\n")).forEach(LOG::info);
});
LOG.info(getSeparator("=", message));
}
}
private static String getSeparator(String character, String reference) {
return String.join("", Collections.nCopies(reference.length(), character));
}
private static void logHeadline(String headline, String reference) {
logHeadline(headline, reference, () -> {});
}
private static void logHeadline(String headline, String reference, Runnable additional) {
LOG.info(getSeparator("=", reference));
LOG.info(headline);
additional.run();
LOG.info(getSeparator("=", reference));
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2018 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
*
* http://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.moduliths.test;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.extension.ExtendWith;
import org.moduliths.model.Module.DependencyDepth;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.autoconfigure.filter.TypeExcludeFilters;
import org.springframework.boot.test.context.SpringBootTestContextBootstrapper;
import org.springframework.core.annotation.AliasFor;
import org.springframework.test.context.BootstrapWith;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.context.TestConstructor.AutowireMode;
import org.springframework.test.context.junit.jupiter.SpringExtension;
/**
* Bootstraps the module containing the package of the test class annotated with {@link ModuleTest}. Will apply the
* following modifications to the Spring Boot configuration:
* <ul>
* <li>Restricts the component scanning to the module's package.
* <li>
* <li>Sets the module's package as the only auto-configuration and entity scan package.
* <li>
* </ul>
*
* @author Oliver Drotbohm
*/
@Retention(RetentionPolicy.RUNTIME)
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@TypeExcludeFilters(ModuleTypeExcludeFilter.class)
@ImportAutoConfiguration(ModuleTestAutoConfiguration.class)
@ExtendWith(SpringExtension.class)
@ExtendWith(PublishedEventsParameterResolver.class)
@TestInstance(Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = AutowireMode.ALL)
public @interface ModuleTest {
@AliasFor("mode")
BootstrapMode value() default BootstrapMode.STANDALONE;
@AliasFor("value")
BootstrapMode mode() default BootstrapMode.STANDALONE;
/**
* Whether to automatically verify the module structure for validity.
*
* @return
*/
boolean verifyAutomatically() default true;
/**
* Module names of modules to be included in the test run independent of what the {@link #mode()} defines.
*
* @return
*/
String[] extraIncludes() default {};
@RequiredArgsConstructor
public enum BootstrapMode {
/**
* Boorstraps the current module only.
*/
STANDALONE(DependencyDepth.NONE),
/**
* Bootstraps the current module as well as its direct dependencies.
*/
DIRECT_DEPENDENCIES(DependencyDepth.IMMEDIATE),
/**
* Bootstraps the current module as well as all upstream dependencies (including transitive ones).
*/
ALL_DEPENDENCIES(DependencyDepth.ALL);
private final @Getter DependencyDepth depth;
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2018 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
*
* http://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.moduliths.test;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConstructorArgumentValues.ValueHolder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/**
* An unconditional auto-configuration registering an {@link ImportBeanDefinitionRegistrar} to customize both the entity
* scan and auto-configuration packages to the packages defined by the {@link ModuleTestExecution} in the application
* context.
*
* @author Oliver Gierke
*/
@Configuration
@Import(ModuleTestAutoConfiguration.AutoConfigurationAndEntityScanPackageCustomizer.class)
class ModuleTestAutoConfiguration {
private static final String AUTOCONFIG_PACKAGES = "org.springframework.boot.autoconfigure.AutoConfigurationPackages";
private static final String ENTITY_SCAN_PACKAGE = "org.springframework.boot.autoconfigure.domain.EntityScanPackages";
@Slf4j
static class AutoConfigurationAndEntityScanPackageCustomizer implements ImportBeanDefinitionRegistrar {
/*
* (non-Javadoc)
* @see org.springframework.context.annotation.ImportBeanDefinitionRegistrar#registerBeanDefinitions(org.springframework.core.type.AnnotationMetadata, org.springframework.beans.factory.support.BeanDefinitionRegistry)
*/
@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
ModuleTestExecution execution = ((BeanFactory) registry).getBean(ModuleTestExecution.class);
List<String> basePackages = execution.getBasePackages().collect(Collectors.toList());
LOG.info("Re-configuring auto-configuration and entity scan packages to: {}.",
StringUtils.collectionToDelimitedString(basePackages, ", "));
setBasePackagesOn(registry, AUTOCONFIG_PACKAGES, "BasePackagesBeanDefinition", "basePackages", basePackages);
setBasePackagesOn(registry, ENTITY_SCAN_PACKAGE, "EntityScanPackagesBeanDefinition", "packageNames",
basePackages);
}
@SuppressWarnings("unchecked")
private void setBasePackagesOn(BeanDefinitionRegistry registry, String beanName, String definitionType,
String fieldName, List<String> packages) {
if (!registry.containsBeanDefinition(beanName)) {
return;
}
BeanDefinition definition = registry.getBeanDefinition(beanName);
// For Boot 2.4, we deal with a BasePackagesBeanDefinition
Field field = Arrays.stream(definition.getClass().getDeclaredFields())
.filter(__ -> definition.getClass().getSimpleName().equals(definitionType))
.filter(it -> it.getName().equals(fieldName))
.findFirst()
.orElse(null);
if (field != null) {
// Keep all auto-configuration packages from Moduliths
ReflectionUtils.makeAccessible(field);
((Set<String>) ReflectionUtils.getField(field, definition)).stream()
.filter(it -> it.startsWith("org.moduliths"))
.forEach(packages::add);
ReflectionUtils.setField(field, definition, new HashSet<>(packages));
} else {
ValueHolder holder = definition.getConstructorArgumentValues().getArgumentValue(0, String[].class);
Arrays.stream((String[]) holder.getValue())
.filter(it -> it.startsWith("org.moduliths"))
.forEach(packages::add);
// Fall back to customize the bean definition in a Boot 2.3 arrangement
definition.getConstructorArgumentValues().addIndexedArgumentValue(0, packages);
}
}
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright 2018-2019 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
*
* http://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.moduliths.test;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.moduliths.model.JavaPackage;
import org.moduliths.model.Module;
import org.moduliths.model.Modules;
import org.moduliths.test.ModuleTest.BootstrapMode;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.AnnotatedClassFinder;
import org.springframework.core.annotation.AnnotatedElementUtils;
import com.tngtech.archunit.thirdparty.com.google.common.base.Supplier;
import com.tngtech.archunit.thirdparty.com.google.common.base.Suppliers;
/**
* @author Oliver Gierke
*/
@Slf4j
@EqualsAndHashCode(of = "key")
public class ModuleTestExecution implements Iterable<Module> {
private static Map<Class<?>, Class<?>> MODULITH_TYPES = new HashMap<>();
private static Map<Key, ModuleTestExecution> EXECUTIONS = new HashMap<>();
private final Key key;
private final @Getter BootstrapMode bootstrapMode;
private final @Getter Module module;
private final @Getter Modules modules;
private final @Getter List<Module> extraIncludes;
private final Supplier<List<JavaPackage>> basePackages;
private final Supplier<List<Module>> dependencies;
private ModuleTestExecution(ModuleTest annotation, Modules modules, Module module) {
this.key = Key.of(module.getBasePackage().getName(), annotation);
this.modules = modules;
this.bootstrapMode = annotation.mode();
this.module = module;
this.extraIncludes = getExtraModules(annotation, modules).collect(Collectors.toList());
this.basePackages = Suppliers.memoize(() -> {
Stream<JavaPackage> moduleBasePackages = module.getBasePackages(modules, bootstrapMode.getDepth());
Stream<JavaPackage> sharedBasePackages = modules.getSharedModules().stream().map(it -> it.getBasePackage());
Stream<JavaPackage> extraPackages = extraIncludes.stream().map(Module::getBasePackage);
Stream<JavaPackage> intermediate = Stream.concat(moduleBasePackages, extraPackages);
return Stream.concat(intermediate, sharedBasePackages).distinct().collect(Collectors.toList());
});
this.dependencies = Suppliers.memoize(() -> {
Stream<Module> bootstrapDependencies = module.getBootstrapDependencies(modules, bootstrapMode.getDepth());
return Stream.concat(bootstrapDependencies, extraIncludes.stream()).collect(Collectors.toList());
});
if (annotation.verifyAutomatically()) {
verify();
}
}
public static java.util.function.Supplier<ModuleTestExecution> of(Class<?> type) {
return () -> {
ModuleTest annotation = AnnotatedElementUtils.findMergedAnnotation(type, ModuleTest.class);
String packageName = type.getPackage().getName();
Class<?> modulithType = MODULITH_TYPES.computeIfAbsent(type,
it -> new AnnotatedClassFinder(SpringBootApplication.class).findFromPackage(packageName));
Modules modules = Modules.of(modulithType);
Module module = modules.getModuleForPackage(packageName) //
.orElseThrow(
() -> new IllegalStateException(String.format("Package %s is not part of any module!", packageName)));
return EXECUTIONS.computeIfAbsent(Key.of(module.getBasePackage().getName(), annotation),
it -> new ModuleTestExecution(annotation, modules, module));
};
}
/**
* Returns all base packages the current execution needs to use for component scanning, auto-configuration etc.
*
* @return
*/
public Stream<String> getBasePackages() {
return basePackages.get().stream().map(JavaPackage::getName);
}
public boolean includes(String className) {
boolean result = modules.withinRootPackages(className) //
|| basePackages.get().stream().anyMatch(it -> it.contains(className));
if (result) {
LOG.debug("Including class {}.", className);
}
return !result;
}
/**
* Returns all module dependencies, based on the current {@link BootstrapMode}.
*
* @return
*/
public List<Module> getDependencies() {
return dependencies.get();
}
/**
* Explicitly trigger the module structure verification.
*/
public void verify() {
modules.verify();
}
/**
* Verifies the setup of the module bootstrapped by this execution.
*/
public void verifyModule() {
module.verifyDependencies(modules);
}
/*
* (non-Javadoc)
* @see java.lang.Iterable#iterator()
*/
@Override
public Iterator<Module> iterator() {
return modules.iterator();
}
private static Stream<Module> getExtraModules(ModuleTest annotation, Modules modules) {
return Arrays.stream(annotation.extraIncludes()) //
.map(modules::getModuleByName) //
.flatMap(it -> it.map(Stream::of).orElseGet(Stream::empty));
}
@Value
@RequiredArgsConstructor(staticName = "of", access = AccessLevel.PRIVATE)
private static class Key {
String moduleBasePackage;
ModuleTest annotation;
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2018 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
*
* http://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.moduliths.test;
import lombok.EqualsAndHashCode;
import java.io.IOException;
import java.util.function.Supplier;
import org.springframework.boot.context.TypeExcludeFilter;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
/**
* @author Oliver Gierke
*/
@EqualsAndHashCode(callSuper = false)
class ModuleTypeExcludeFilter extends TypeExcludeFilter {
private final Supplier<ModuleTestExecution> execution;
public ModuleTypeExcludeFilter(Class<?> testClass) {
this.execution = ModuleTestExecution.of(testClass);
}
/*
* (non-Javadoc)
* @see org.springframework.boot.context.TypeExcludeFilter#match(org.springframework.core.type.classreading.MetadataReader, org.springframework.core.type.classreading.MetadataReaderFactory)
*/
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
return execution.get().includes(metadataReader.getClassMetadata().getClassName());
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2019-2020 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.moduliths.test;
import java.util.Arrays;
import java.util.Collection;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.util.Assert;
/**
* All Spring application events fired during the test execution.
*
* @author Oliver Drotbohm
*/
public interface PublishedEvents {
/**
* Creates a new {@link PublishedEvents} instance for the given events.
*
* @param events must not be {@literal null}.
* @return will never be {@literal null}.
*/
public static PublishedEvents of(Object... events) {
return of(Arrays.asList(events));
}
/**
* Creates a new {@link PublishedEvents} instance for the given events.
*
* @param events must not be {@literal null}.
* @return
*/
public static PublishedEvents of(Collection<? extends Object> events) {
Assert.notNull(events, "Events must not be null!");
return new DefaultPublishedEvents(events);
}
/**
* Returns all application events of the given type that were fired during the test execution.
*
* @param <T> the event type
* @param type must not be {@literal null}.
* @return
*/
<T> TypedPublishedEvents<T> ofType(Class<T> type);
/**
* All application events of a given type that were fired during a test execution.
*
* @author Oliver Drotbohm
* @param <T> the event type
*/
interface TypedPublishedEvents<T> extends Iterable<T> {
/**
* Further constrain the event type for downstream assertions.
*
* @param <S>
* @param subType the sub type
* @return will never be {@literal null}.
*/
<S extends T> TypedPublishedEvents<S> ofSubType(Class<S> subType);
/**
* Returns all {@link TypedPublishedEvents} that match the given predicate.
*
* @param predicate must not be {@literal null}.
* @return will never be {@literal null}.
*/
TypedPublishedEvents<T> matching(Predicate<? super T> predicate);
/**
* Returns all {@link TypedPublishedEvents} that match the given predicate after applying the given mapping step.
*
* @param <S> the intermediate type to apply the {@link Predicate} on
* @param mapper the mapping step to extract a part of the original event subject to test for the {@link Predicate}.
* @param predicate the {@link Predicate} to apply on the value extracted.
* @return will never be {@literal null}.
*/
<S> TypedPublishedEvents<T> matchingMapped(Function<T, S> mapper, Predicate<? super S> predicate);
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2020 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.moduliths.test;
import org.junit.jupiter.api.extension.Extension;
/**
* JUnit 5 {@link Extension} for standalone usage without {@link ModuleTest}.
*
* @author Oliver Drotbohm
*/
public final class PublishedEventsExtension extends PublishedEventsParameterResolver {
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2019 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.moduliths.test;
import java.util.function.Function;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.Assert;
/**
* Provides instances of {@link PublishedEvents} as test method parameters.
*
* @author Oliver Drotbohm
*/
class PublishedEventsParameterResolver implements ParameterResolver, BeforeAllCallback, AfterEachCallback {
private ThreadBoundApplicationListenerAdapter listener = new ThreadBoundApplicationListenerAdapter();
private final Function<ExtensionContext, ApplicationContext> lookup;
PublishedEventsParameterResolver() {
this(ctx -> SpringExtension.getApplicationContext(ctx));
}
PublishedEventsParameterResolver(Function<ExtensionContext, ApplicationContext> supplier) {
this.lookup = supplier;
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.BeforeAllCallback#beforeAll(org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public void beforeAll(ExtensionContext extensionContext) {
ApplicationContext context = lookup.apply(extensionContext);
((ConfigurableApplicationContext) context).addApplicationListener(listener);
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.ParameterResolver#supportsParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return PublishedEvents.class.isAssignableFrom(parameterContext.getParameter().getType());
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.ParameterResolver#resolveParameter(org.junit.jupiter.api.extension.ParameterContext, org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public PublishedEvents resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
DefaultPublishedEvents publishedEvents = new DefaultPublishedEvents();
listener.registerDelegate(publishedEvents);
return publishedEvents;
}
/*
* (non-Javadoc)
* @see org.junit.jupiter.api.extension.AfterEachCallback#afterEach(org.junit.jupiter.api.extension.ExtensionContext)
*/
@Override
public void afterEach(ExtensionContext context) {
listener.unregisterDelegate();
}
/**
* {@link ApplicationListener} that allows registering delegate {@link ApplicationListener}s that are held in a
* {@link ThreadLocal} and get used on {@link #onApplicationEvent(ApplicationEvent)} if one is registered for the
* current thread. This allows multiple event listeners to see the events fired in a certain thread in a concurrent
* execution scenario.
*
* @author Oliver Drotbohm
*/
private static class ThreadBoundApplicationListenerAdapter implements ApplicationListener<ApplicationEvent> {
private final ThreadLocal<ApplicationListener<ApplicationEvent>> delegate = new ThreadLocal<>();
/**
* Registers the given {@link ApplicationListener} to be used for the current thread.
*
* @param listener must not be {@literal null}.
*/
void registerDelegate(ApplicationListener<ApplicationEvent> listener) {
Assert.notNull(listener, "Delegate ApplicationListener must not be null!");
delegate.set(listener);
}
/**
* Removes the registration of the currently assigned {@link ApplicationListener}.
*/
void unregisterDelegate() {
delegate.remove();
}
/*
* (non-Javadoc)
* @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent)
*/
@Override
public void onApplicationEvent(ApplicationEvent event) {
ApplicationListener<ApplicationEvent> listener = delegate.get();
if (listener != null) {
listener.onApplicationEvent(event);
}
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2018 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
*
* http://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.moduliths.test;
import static org.assertj.core.api.Assertions.*;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.UnsatisfiedDependencyException;
import org.springframework.boot.test.context.SpringBootContextLoader;
import org.springframework.boot.test.context.SpringBootTestContextBootstrapper;
import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.BootstrapContext;
import org.springframework.test.context.CacheAwareContextLoaderDelegate;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate;
import org.springframework.test.context.support.DefaultBootstrapContext;
/**
* @author Oliver Gierke
*/
public class TestUtils {
public static void assertDependencyMissing(Class<?> testClass, Class<?> expectedMissingDependency) {
CacheAwareContextLoaderDelegate delegate = new DefaultCacheAwareContextLoaderDelegate();
BootstrapContext bootstrapContext = new DefaultBootstrapContext(testClass, delegate);
SpringBootTestContextBootstrapper bootstrapper = new SpringBootTestContextBootstrapper();
bootstrapper.setBootstrapContext(bootstrapContext);
MergedContextConfiguration configuration = bootstrapper.buildMergedContextConfiguration();
AssertableApplicationContext context = AssertableApplicationContext.get(() -> {
SpringBootContextLoader loader = new SpringBootContextLoader();
try {
return (ConfigurableApplicationContext) loader.loadContext(configuration);
} catch (Exception e) {
if (e instanceof RuntimeException) {
throw (RuntimeException) e;
}
throw new RuntimeException(e);
}
});
assertThat(context).hasFailed();
assertThat(context).getFailure().isInstanceOfSatisfying(UnsatisfiedDependencyException.class, it -> {
assertThat(it.getMostSpecificCause()).isInstanceOfSatisfying(NoSuchBeanDefinitionException.class, ex -> {
assertThat(ex.getBeanType()).isEqualTo(expectedMissingDependency);
});
});
}
}

View File

@@ -0,0 +1 @@
org.springframework.test.context.ContextCustomizerFactory=org.moduliths.test.ModuleContextCustomizerFactory

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2020 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.moduliths.test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ParameterContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.util.ReflectionUtils;
/**
* Unit tests for PublishedEventsParameterResolver.
*
* @author Oliver Drotbohm
*/
public class PublishedEventsParameterResolverUnitTests {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
@Test
void supportsPublishedEventsType() throws Exception {
PublishedEventsParameterResolver resolver = new PublishedEventsParameterResolver(__ -> context);
assertThat(resolver.supportsParameter(getParameterContext(PublishedEvents.class), null)).isTrue();
assertThat(resolver.supportsParameter(getParameterContext(Object.class), null)).isFalse();
}
@Test
void createsThreadBoundPublishedEvents() throws Exception {
PublishedEventsParameterResolver resolver = new PublishedEventsParameterResolver(__ -> context);
context.refresh();
resolver.beforeAll(null);
Map<String, PublishedEvents> allEvents = new ConcurrentHashMap<>();
List<String> keys = Arrays.asList("first", "second", "third");
CountDownLatch latch = new CountDownLatch(3);
for (String it : keys) {
new Thread(() -> {
PublishedEvents events = resolver.resolveParameter(null, null);
context.publishEvent(it);
allEvents.put(it, events);
resolver.afterEach(null);
latch.countDown();
}).start();
}
latch.await(50, TimeUnit.MILLISECONDS);
keys.forEach(it -> {
assertThat(allEvents.get(it).ofType(String.class)).containsExactly(it);
});
}
private static ParameterContext getParameterContext(Class<?> type) {
Method method = ReflectionUtils.findMethod(Methods.class, "with", type);
ParameterContext context = mock(ParameterContext.class);
doReturn(method.getParameters()[0]).when(context).getParameter();
return context;
}
interface Methods {
void with(PublishedEvents events);
void with(Object object);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2020 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.moduliths.test;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link PublishedEvents}.
*
* @author Oliver Drotbohm
*/
class PublishedEventsUnitTests {
@Test
void createsInstanceFromEvents() {
Object reference = new Object();
PublishedEvents events = PublishedEvents.of(reference);
assertThat(events.ofType(Object.class)).containsExactly(reference);
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %5p %40.40c:%4L - %m%n</pattern>
</encoder>
</appender>
<!-- <logger name="org.moduliths" level="debug" /> -->
<root level="error">
<appender-ref ref="console" />
</root>
</configuration>