#778 Delayed runner: add support for annotations, add validation

- Add support for dependencies identified by annotations
- Add some more usage validation
- Change a few test classes to use the DelayedInjectionRunner
This commit is contained in:
ljacqu 2016-06-19 22:54:12 +02:00
parent 4b3ab4b116
commit e7ba579960
9 changed files with 203 additions and 108 deletions

View File

@ -7,7 +7,6 @@ import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.initialization.AuthMeServiceInitializer; import fr.xephi.authme.initialization.AuthMeServiceInitializer;
import fr.xephi.authme.initialization.DataFolder; import fr.xephi.authme.initialization.DataFolder;
import fr.xephi.authme.listener.AuthMeBlockListener; import fr.xephi.authme.listener.AuthMeBlockListener;
import fr.xephi.authme.output.Messages;
import fr.xephi.authme.permission.PermissionsManager; import fr.xephi.authme.permission.PermissionsManager;
import fr.xephi.authme.process.Management; import fr.xephi.authme.process.Management;
import fr.xephi.authme.process.login.ProcessSyncPlayerLogin; import fr.xephi.authme.process.login.ProcessSyncPlayerLogin;
@ -106,7 +105,6 @@ public class AuthMeInitializationTest {
initializer.register(AuthMe.class, authMe); initializer.register(AuthMe.class, authMe);
initializer.register(NewSetting.class, settings); initializer.register(NewSetting.class, settings);
initializer.register(DataSource.class, mock(DataSource.class)); initializer.register(DataSource.class, mock(DataSource.class));
initializer.register(Messages.class, mock(Messages.class));
// when // when
authMe.instantiateServices(initializer); authMe.instantiateServices(initializer);

View File

@ -7,14 +7,15 @@ import fr.xephi.authme.command.TestCommandsUtil.TestUnregisterCommand;
import fr.xephi.authme.command.help.HelpProvider; import fr.xephi.authme.command.help.HelpProvider;
import fr.xephi.authme.initialization.AuthMeServiceInitializer; import fr.xephi.authme.initialization.AuthMeServiceInitializer;
import fr.xephi.authme.permission.PermissionsManager; import fr.xephi.authme.permission.PermissionsManager;
import fr.xephi.authme.runner.BeforeInjecting;
import fr.xephi.authme.runner.DelayedInjectionRunner;
import fr.xephi.authme.runner.InjectDelayed;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock; import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer; import org.mockito.stubbing.Answer;
import java.util.Collections; import java.util.Collections;
@ -48,9 +49,10 @@ import static org.mockito.Mockito.verify;
// Justification: It's more readable to use asList() everywhere in the test when we often generated two lists where one // Justification: It's more readable to use asList() everywhere in the test when we often generated two lists where one
// often consists of only one element, e.g. myMethod(asList("authme"), asList("my", "args"), ...) // often consists of only one element, e.g. myMethod(asList("authme"), asList("my", "args"), ...)
@SuppressWarnings("ArraysAsListWithZeroOrOneArgument") @SuppressWarnings("ArraysAsListWithZeroOrOneArgument")
@RunWith(MockitoJUnitRunner.class) @RunWith(DelayedInjectionRunner.class)
public class CommandHandlerTest { public class CommandHandlerTest {
@InjectDelayed
private CommandHandler handler; private CommandHandler handler;
@Mock @Mock
@ -64,13 +66,12 @@ public class CommandHandlerTest {
private Map<Class<? extends ExecutableCommand>, ExecutableCommand> mockedCommands = new HashMap<>(); private Map<Class<? extends ExecutableCommand>, ExecutableCommand> mockedCommands = new HashMap<>();
@Before @BeforeInjecting
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void initializeCommandMapper() { public void initializeCommandMapper() {
given(commandMapper.getCommandClasses()).willReturn(Sets.newHashSet(ExecutableCommand.class, given(commandMapper.getCommandClasses()).willReturn(Sets.newHashSet(
TestLoginCommand.class, TestRegisterCommand.class, TestUnregisterCommand.class)); ExecutableCommand.class, TestLoginCommand.class, TestRegisterCommand.class, TestUnregisterCommand.class));
setInjectorToMockExecutableCommandClasses(); setInjectorToMockExecutableCommandClasses();
handler = new CommandHandler(initializer, commandMapper, permissionsManager, helpProvider);
} }
/** /**

View File

@ -3,16 +3,17 @@ package fr.xephi.authme.converter;
import fr.xephi.authme.TestHelper; import fr.xephi.authme.TestHelper;
import fr.xephi.authme.cache.auth.PlayerAuth; import fr.xephi.authme.cache.auth.PlayerAuth;
import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.initialization.DataFolder;
import fr.xephi.authme.runner.DelayedInjectionRunner;
import fr.xephi.authme.runner.InjectDelayed;
import fr.xephi.authme.settings.NewSetting; import fr.xephi.authme.settings.NewSetting;
import fr.xephi.authme.settings.properties.ConverterSettings; import fr.xephi.authme.settings.properties.ConverterSettings;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.junit.Before;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import java.io.File; import java.io.File;
import java.util.List; import java.util.List;
@ -31,9 +32,10 @@ import static org.mockito.Mockito.verifyZeroInteractions;
/** /**
* Test for {@link CrazyLoginConverter}. * Test for {@link CrazyLoginConverter}.
*/ */
@RunWith(MockitoJUnitRunner.class) @RunWith(DelayedInjectionRunner.class)
public class CrazyLoginConverterTest { public class CrazyLoginConverterTest {
@InjectDelayed
private CrazyLoginConverter crazyLoginConverter; private CrazyLoginConverter crazyLoginConverter;
@Mock @Mock
@ -42,6 +44,7 @@ public class CrazyLoginConverterTest {
@Mock @Mock
private NewSetting settings; private NewSetting settings;
@DataFolder
private File dataFolder = TestHelper.getJarFile("/converter/"); private File dataFolder = TestHelper.getJarFile("/converter/");
@BeforeClass @BeforeClass
@ -49,11 +52,6 @@ public class CrazyLoginConverterTest {
TestHelper.setupLogger(); TestHelper.setupLogger();
} }
@Before
public void instantiateConverter() {
crazyLoginConverter = new CrazyLoginConverter(dataFolder, dataSource, settings);
}
@Test @Test
public void shouldImportUsers() { public void shouldImportUsers() {
// given // given

View File

@ -1,6 +1,5 @@
package fr.xephi.authme.runner; package fr.xephi.authme.runner;
import fr.xephi.authme.ReflectionTestUtils;
import fr.xephi.authme.initialization.Injection; import fr.xephi.authme.initialization.Injection;
import fr.xephi.authme.initialization.InjectionHelper; import fr.xephi.authme.initialization.InjectionHelper;
import org.junit.runner.notification.RunNotifier; import org.junit.runner.notification.RunNotifier;
@ -11,13 +10,9 @@ import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement; import org.junit.runners.model.Statement;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockitoAnnotations; import org.mockito.MockitoAnnotations;
import org.mockito.internal.runners.util.FrameworkUsageValidator;
import java.lang.reflect.Field;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Custom JUnit runner which adds support for {@link InjectDelayed} and {@link BeforeInjecting}. * Custom JUnit runner which adds support for {@link InjectDelayed} and {@link BeforeInjecting}.
@ -63,7 +58,7 @@ public class DelayedInjectionRunner extends BlockJUnit4ClassRunner {
@Override @Override
public void run(final RunNotifier notifier) { public void run(final RunNotifier notifier) {
// add listener that validates framework usage at the end of each test // add listener that validates framework usage at the end of each test
notifier.addListener(new FrameworkUsageValidator(notifier)); notifier.addListener(new DelayedInjectionRunnerValidator(notifier, getTestClass()));
super.run(notifier); super.run(notifier);
} }
@ -87,53 +82,23 @@ public class DelayedInjectionRunner extends BlockJUnit4ClassRunner {
List<PendingInjection> pendingInjections = new ArrayList<>(delayedFields.size()); List<PendingInjection> pendingInjections = new ArrayList<>(delayedFields.size());
for (FrameworkField field : delayedFields) { for (FrameworkField field : delayedFields) {
pendingInjections.add(createPendingInjection(target, field.getField())); pendingInjections.add(new PendingInjection(field.getField(), getInjection(field)));
} }
return new RunDelayedInjects(statement, pendingInjections, target); InjectionResolver injectionResolver = new InjectionResolver(getTestClass(), target);
return new RunDelayedInjects(statement, pendingInjections, target, injectionResolver);
} }
/** /**
* Creates a {@link PendingInjection} for the given field's type, using the target's values. * Gets the injection method for the given field's type and ensures an injection method has been found.
* *
* @param target the target to get dependencies from * @param field the field to get the injection for
* @param field the field to prepare an injection for * @return the injection
* @return the resulting object
*/ */
private PendingInjection createPendingInjection(Object target, Field field) { private static Injection<?> getInjection(FrameworkField field) {
final Injection<?> injection = InjectionHelper.getInjection(field.getType()); final Injection<?> injection = InjectionHelper.getInjection(field.getType());
if (injection == null) { if (injection == null) {
throw new IllegalStateException("No injection method available for field '" + field.getName() + "'"); throw new IllegalStateException("No injection method available for field '" + field.getName() + "'");
} }
final Object[] dependencies = fulfillDependencies(target, injection.getDependencies()); return injection;
return new PendingInjection(field, injection, dependencies);
}
/**
* Returns a list of all objects for the given list of dependencies, retrieved from the given
* target's {@link @Mock} fields.
*
* @param target the target to get the required dependencies from
* @param dependencies the required dependency types
* @return the resolved dependencies
*/
private Object[] fulfillDependencies(Object target, Class<?>[] dependencies) {
List<FrameworkField> availableMocks = getTestClass().getAnnotatedFields(Mock.class);
Map<Class<?>, Object> mocksByType = new HashMap<>();
for (FrameworkField frameworkField : availableMocks) {
Field field = frameworkField.getField();
Object fieldValue = ReflectionTestUtils.getFieldValue(field, target);
mocksByType.put(field.getType(), fieldValue);
}
Object[] resolvedValues = new Object[dependencies.length];
for (int i = 0; i < dependencies.length; ++i) {
Object o = mocksByType.get(dependencies[i]);
if (o == null) {
throw new IllegalStateException("No mock found for '" + dependencies[i] + "'. "
+ "All dependencies of @InjectDelayed must be provided as @Mock fields");
}
resolvedValues[i] = o;
}
return resolvedValues;
} }
} }

View File

@ -0,0 +1,36 @@
package fr.xephi.authme.runner;
import org.junit.runner.Description;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.model.TestClass;
import org.mockito.InjectMocks;
import org.mockito.Mockito;
/**
* Validates that {@link DelayedInjectionRunner} is used as intended.
*/
class DelayedInjectionRunnerValidator extends RunListener {
private final RunNotifier notifier;
private final TestClass testClass;
public DelayedInjectionRunnerValidator(RunNotifier notifier, TestClass testClass) {
this.notifier = notifier;
this.testClass = testClass;
}
@Override
public void testFinished(Description description) throws Exception {
try {
Mockito.validateMockitoUsage();
if (!testClass.getAnnotatedFields(InjectMocks.class).isEmpty()) {
throw new IllegalStateException("Do not use @InjectMocks with the DelayedInjectionRunner:" +
" use @InjectDelayed or change runner");
}
} catch (Throwable t) {
notifier.fireTestFailure(new Failure(description, t));
}
}
}

View File

@ -0,0 +1,104 @@
package fr.xephi.authme.runner;
import fr.xephi.authme.ReflectionTestUtils;
import fr.xephi.authme.initialization.Injection;
import fr.xephi.authme.initialization.InjectionHelper;
import org.junit.runners.model.FrameworkField;
import org.junit.runners.model.TestClass;
import org.mockito.Mock;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Resolves the dependencies of an injection based on the provided {@link TestClass} and {@link #target target}.
*/
class InjectionResolver {
private final TestClass testClass;
private final Object target;
private final Map<Class<?>, Object> mocksByType;
public InjectionResolver(TestClass testClass, Object target) {
this.testClass = testClass;
this.target = target;
this.mocksByType = gatherAvailableMocks();
}
public Object instantiate(Injection<?> injection) {
Object[] dependencies = resolveDependencies(injection);
Object object = injection.instantiateWith(dependencies);
executePostConstructMethod(object);
return object;
}
/**
* Returns a list of all objects for the given list of dependencies, retrieved from the given
* target's {@link Mock} fields.
*
* @param injection the injection whose dependencies to gather
* @return the resolved dependencies
*/
private Object[] resolveDependencies(Injection<?> injection) {
final Class<?>[] dependencies = injection.getDependencies();
final Class<?>[] annotations = injection.getDependencyAnnotations();
Object[] resolvedValues = new Object[dependencies.length];
for (int i = 0; i < dependencies.length; ++i) {
Object dependency = (annotations[i] == null)
? resolveDependency(dependencies[i])
: resolveAnnotation(annotations[i]);
resolvedValues[i] = dependency;
}
return resolvedValues;
}
private Object resolveDependency(Class<?> clazz) {
Object o = mocksByType.get(clazz);
if (o == null) {
throw new IllegalStateException("No mock found for '" + clazz + "'. "
+ "All dependencies of @InjectDelayed must be provided as @Mock fields");
}
return o;
}
private Object resolveAnnotation(Class<?> clazz) {
Class<? extends Annotation> annotation = (Class<? extends Annotation>) clazz;
List<FrameworkField> matches = testClass.getAnnotatedFields(annotation);
if (matches.isEmpty()) {
throw new IllegalStateException("No field found with @" + annotation.getSimpleName() + " in test class,"
+ "but a dependency in an @InjectDelayed field is using it");
} else if (matches.size() > 1) {
throw new IllegalStateException("You cannot have multiple fields with @" + annotation.getSimpleName());
}
return ReflectionTestUtils.getFieldValue(matches.get(0).getField(), target);
}
/**
* Executes the class' PostConstruct method if available. Validates that all rules for
* {@link javax.annotation.PostConstruct} are met.
*
* @param object the object whose PostConstruct method should be run, if available
* @see InjectionHelper#getAndValidatePostConstructMethod
*/
private static void executePostConstructMethod(Object object) {
Method postConstructMethod = InjectionHelper.getAndValidatePostConstructMethod(object.getClass());
if (postConstructMethod != null) {
ReflectionTestUtils.invokeMethod(postConstructMethod, object);
}
}
private Map<Class<?>, Object> gatherAvailableMocks() {
List<FrameworkField> availableMocks = testClass.getAnnotatedFields(Mock.class);
Map<Class<?>, Object> mocksByType = new HashMap<>();
for (FrameworkField frameworkField : availableMocks) {
Field field = frameworkField.getField();
Object fieldValue = ReflectionTestUtils.getFieldValue(field, target);
mocksByType.put(field.getType(), fieldValue);
}
return mocksByType;
}
}

View File

@ -1,36 +1,29 @@
package fr.xephi.authme.runner; package fr.xephi.authme.runner;
import fr.xephi.authme.ReflectionTestUtils;
import fr.xephi.authme.initialization.Injection; import fr.xephi.authme.initialization.Injection;
import fr.xephi.authme.initialization.InjectionHelper;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method;
/** /**
* Contains all necessary information to initialize a {@link InjectDelayed} field. * Contains an injection and the field it's for.
*/ */
class PendingInjection { class PendingInjection {
private Field field; private final Field field;
private Object[] dependencies; private final Injection<?> injection;
private Injection<?> injection;
public PendingInjection(Field field, Injection<?> injection, Object[] dependencies) { public PendingInjection(Field field, Injection<?> injection) {
this.field = field; this.field = field;
this.injection = injection; this.injection = injection;
this.dependencies = dependencies;
} }
/** /**
* Constructs an object with the stored injection information. * Returns the injection to perform.
* *
* @return the constructed object * @return the injection
*/ */
public Object instantiate() { public Injection<?> getInjection() {
Object object = injection.instantiateWith(dependencies); return injection;
executePostConstructMethod(object);
return object;
} }
/** /**
@ -42,26 +35,4 @@ class PendingInjection {
return field; return field;
} }
/**
* Clears all fields (avoids keeping a reference to all dependencies).
*/
public void clearFields() {
field = null;
dependencies = null;
injection = null;
}
/**
* Executes the class' PostConstruct method if available. Validates that all rules for
* {@link javax.annotation.PostConstruct} are met.
*
* @param object the object whose PostConstruct method should be run, if available
* @see InjectionHelper#getAndValidatePostConstructMethod
*/
private static void executePostConstructMethod(Object object) {
Method postConstructMethod = InjectionHelper.getAndValidatePostConstructMethod(object.getClass());
if (postConstructMethod != null) {
ReflectionTestUtils.invokeMethod(postConstructMethod, object);
}
}
} }

View File

@ -13,21 +13,28 @@ class RunDelayedInjects extends Statement {
private final Statement next; private final Statement next;
private final Object target; private final Object target;
private final List<PendingInjection> pendingInjections; private List<PendingInjection> pendingInjections;
private InjectionResolver injectionResolver;
public RunDelayedInjects(Statement next, List<PendingInjection> pendingInjections, Object target) { public RunDelayedInjects(Statement next, List<PendingInjection> pendingInjections, Object target,
InjectionResolver injectionResolver) {
this.next = next; this.next = next;
this.pendingInjections = pendingInjections; this.pendingInjections = pendingInjections;
this.target = target; this.target = target;
this.injectionResolver = injectionResolver;
} }
@Override @Override
public void evaluate() throws Throwable { public void evaluate() throws Throwable {
for (PendingInjection pendingInjection : pendingInjections) { for (PendingInjection pendingInjection : pendingInjections) {
Object object = pendingInjection.instantiate(); if (ReflectionTestUtils.getFieldValue(pendingInjection.getField(), target) != null) {
ReflectionTestUtils.setField(pendingInjection.getField(), target, object); throw new IllegalStateException("Field with @InjectDelayed must be null on startup");
pendingInjection.clearFields();
} }
Object object = injectionResolver.instantiate(pendingInjection.getInjection());
ReflectionTestUtils.setField(pendingInjection.getField(), target, object);
}
this.pendingInjections = null;
this.injectionResolver = null;
next.evaluate(); next.evaluate();
} }
} }

View File

@ -4,14 +4,19 @@ import com.google.common.io.Files;
import fr.xephi.authme.TestHelper; import fr.xephi.authme.TestHelper;
import fr.xephi.authme.datasource.DataSource; import fr.xephi.authme.datasource.DataSource;
import fr.xephi.authme.hooks.PluginHooks; import fr.xephi.authme.hooks.PluginHooks;
import fr.xephi.authme.initialization.DataFolder;
import fr.xephi.authme.runner.BeforeInjecting;
import fr.xephi.authme.runner.DelayedInjectionRunner;
import fr.xephi.authme.runner.InjectDelayed;
import fr.xephi.authme.settings.properties.RestrictionSettings; import fr.xephi.authme.settings.properties.RestrictionSettings;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -24,15 +29,28 @@ import static org.mockito.Mockito.mock;
/** /**
* Test for {@link SpawnLoader}. * Test for {@link SpawnLoader}.
*/ */
@RunWith(DelayedInjectionRunner.class)
public class SpawnLoaderTest { public class SpawnLoaderTest {
@InjectDelayed
private SpawnLoader spawnLoader;
@Mock
private NewSetting settings;
@Mock
private DataSource dataSource;
@Mock
private PluginHooks pluginHooks;
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@DataFolder
private File testFolder; private File testFolder;
private NewSetting settings;
@Before @BeforeInjecting
public void setup() throws IOException { public void setup() throws IOException {
// Copy test config into a new temporary folder // Copy test config into a new temporary folder
testFolder = temporaryFolder.newFolder(); testFolder = temporaryFolder.newFolder();
@ -41,7 +59,6 @@ public class SpawnLoaderTest {
Files.copy(source, destination); Files.copy(source, destination);
// Create a settings mock with default values // Create a settings mock with default values
settings = mock(NewSetting.class);
given(settings.getProperty(RestrictionSettings.SPAWN_PRIORITY)) given(settings.getProperty(RestrictionSettings.SPAWN_PRIORITY))
.willReturn("authme, essentials, multiverse, default"); .willReturn("authme, essentials, multiverse, default");
} }
@ -49,8 +66,6 @@ public class SpawnLoaderTest {
@Test @Test
public void shouldSetSpawn() { public void shouldSetSpawn() {
// given // given
SpawnLoader spawnLoader =
new SpawnLoader(testFolder, settings, mock(PluginHooks.class), mock(DataSource.class));
World world = mock(World.class); World world = mock(World.class);
given(world.getName()).willReturn("new_world"); given(world.getName()).willReturn("new_world");
Location newSpawn = new Location(world, 123, 45.0, -67.89); Location newSpawn = new Location(world, 123, 45.0, -67.89);