Implementing a custom annotation with readable attributes in Spring Configuration (test)

409 views Asked by At

I am familiar with composite annotations. However, it seems they are not enough for my specific need even after some research.

General case

I want to create an annotation for testing that, put on a class along with some attributes, can customize the test context.

public @interface MyTestingAnnotation {

    String myTestingAttribute()

}

The value for myTestingAttribute should be read by one of my custom (TBD) @TestConfigurations

Practical example

In my applications, I must mock the clock so that the test can run simulating a specific point of time. E.g. the result of the test does not depend on the hardware clock. I define a java.time.Clock bean for the purpose.

Currently, I only have an annotation to enable the mock clock, but specifying its time depends on @TestPropertySource

/**
 * Provides mocking configuration for the {@link Clock} bean
 * <p></p>
 * Injected in all relevant Spring beans that require time access, can be set to a fixed time in order to provide repeatable
 * tests
 * In general, time zone is assumed to be UTC in all cases.
 * <p></p>
 * This bean reads the property <pre>clock.fixedInstant</pre> to determine the current time that is being mocked to all beans
 * By default, it is a known point in time: <pre>4 November 2023 at 15:45:32 UTC</pre>
 * Individual test can override the fixed time by using
 * <pre>@TestPropertySource(properties = "clock.fixed-instant:[... ISO 8601 ...]")</pre>
 */
@TestConfiguration
public class MockClockConfiguration {

    @Value("${clock.fixed-instant:2023-11-04T15:45:32.000000Z}")
    private OffsetDateTime clockFixedInstant;

    private static final ThreadLocal<OffsetDateTime> fixedInstantLocal = new ThreadLocal<>();

    /**
     * Convenience method for tests to retrieve the fixed time
     * @return
     */
    public static OffsetDateTime getFixedInstant(){
        return fixedInstantLocal.get();
    }

    @Primary
    @Bean
    public Clock fixedClock() {
        fixedInstantLocal.set(clockFixedInstant);
        return Clock.fixed(Instant.from(clockFixedInstant), clockFixedInstant.getOffset());
    }

}

Instead, I would like to annotate a test like @MockClock(at = "2024-02-15T12:35:00+04:00") and not necessarily use the Property source syntax.

I know how to use @AliasFor in my custom annotations, but currently I can only @Import(MockClockConfiguration.class) in my meta-annotations.

How can I achieve something like that in Spring?

1

There are 1 answers

5
rieckpil On

You can use a ContextCustomizer for this purpose.

This approach allows you to modify the application context before tests are run, providing a fine-grained control over the context configuration, including adding or overriding beans, properties, and more.

To achieve your goal with a ContextCustomizer, follow these steps:

  1. Create a Custom Annotation
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface MockClock {
    String at();
}
  1. Implement ContextCustomizer

Create the ContextCustomizer that will modify the application context's environment or bean definitions based on the @MockClock annotation.

import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.MergedContextConfiguration;

import java.time.OffsetDateTime;

public class MockClockContextCustomizer implements ContextCustomizer {

    private final OffsetDateTime mockTime;

    public MockClockContextCustomizer(OffsetDateTime mockTime) {
        this.mockTime = mockTime;
    }

    @Override
    public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
        TestPropertyValues.of(
                        "clock.fixed-instant=" + mockTime.toString())
                .applyTo(context);
    }

    // MockClockContextCustomizer must implement equals() and hashCode(). See the Javadoc for ContextCustomizer for details.
}
  1. Implement ContextCustomizerFactory

Create a ContextCustomizerFactory that looks for your @MockClock annotation on the test class and produces a ContextCustomizer based on the annotation's attributes.

import org.springframework.test.context.ContextConfigurationAttributes;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactory;

import java.time.OffsetDateTime;
import java.util.List;

public class MockClockContextCustomizerFactory implements ContextCustomizerFactory {
    @Override
    public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
        MockClock mockClock = testClass.getAnnotation(MockClock.class);
        if (mockClock != null) {
            return new MockClockContextCustomizer(OffsetDateTime.parse(mockClock.at()));
        }
        return null;
    }
}
  1. Register the ContextCustomizerFactory

Spring does not automatically discover ContextCustomizerFactory implementations. To register your custom factory, you need to create a file named META-INF/spring.factories in the resources directory of your project (if it doesn't already exist) and add the following line:

go

org.springframework.test.context.ContextCustomizerFactory=\
your.package.MockClockContextCustomizerFactory

Replace your.package with the actual package name where your MockClockContextCustomizerFactory is located.

As an alternative, you can register a ContextCustomizerFactory locally via @ContextCustomizerFactories since Spring Framework 6.1.

How It Works

  • When a test class annotated with @MockClock is executed, Spring Test looks for ContextCustomizerFactory implementations specified in spring.factories.
  • Your MockClockContextCustomizerFactory checks for the presence of the @MockClock annotation and, if found, creates an instance of MockClockContextCustomizer with the specified fixed instant.
  • The MockClockContextCustomizer then customizes the application context before the test runs, adding the required property to the environment, which your MockClockConfiguration can use to create the mocked Clock bean.