Search code examples
spring-bootjunitspring-testtemporary-files

In Spring Boot Test, how do I map a temporary folder to a configuration property?


I want to do a self-cleaning test

In my situation, I have one of the components depend on a directory

public class FileRepositoryManagerImpl implements ....

    @Value("${acme.fileRepository.basePath}")
    private File basePath; 
}

The value is defined in the application.yml file, and in DEV it points to a directory under build.

This is not the worst idea, because gradle clean will eventually clean up the mess the tests create.

But, really, what I would like to achieve here, is to make sure that every test runs in an isolated temporary directory that is cleaned up after execution.

I know that JUnit has a tool for the temporary directories. But once I have defined that directory in the scope of JUnit 4, how do I tell Spring to use that temporary directory?

I tried the inner class unsuccessfully:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = { SecurityBeanOverrideConfiguration.class, App.class })
@EnableConfigurationProperties
public abstract class AbstractFileRepositoryManagerIntTests {

    private final static TemporaryFolder temporaryFolder = new TemporaryFolder();

    @ClassRule
    public static TemporaryFolder getTemporaryFolder()
    {
        return temporaryFolder;
    }

    @ConfigurationProperties(prefix = "acme")
    static class Configuration
    {

        public FileRepository getFileRepository()
        {
            return new FileRepository();
        }

        static class FileRepository
        {

            public File basePath() throws Exception
            {
                return temporaryFolder.newFolder("fileRepositoryBaseDir");
            }
        }
    }
}

I was thinking about tinkering with the Environment, but what should be the correct way to inject properties programmatically in a Spring Boot test?


Solution

  • I can think of at least four different approaches to your problem. All with their own advantages and disadvantages.

    Approach 1: ReflectionTestUtils

    You are using @Value annotation on a private instance property (please, don't to that anymore!). Hence, you can not change acme.fileRepository.basePath on the fly without reflection.

    package demo;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.stereotype.Component;
    
    import java.io.File;
    
    @SpringBootApplication
    public class FileRepositoryApp {
    
        public static void main(String[] args) {
            SpringApplication.run(FileRepositoryApp.class, args);
        }
    
        @Component
        public class FileRepository {
    
            @Value("${acme.fileRepository.basePath}")
            private File basePath;
    
            public File getBasePath() {
                return basePath;
            }
        }
    }
    

    Changing basePath after each test with ReflectionTestUtils.setField. Because we are using Spring's TestExecutionListener, that gets initialized before Junit rules are initialized, we are forced to manage the temporary folder in beforeTestExecution and afterTestMethod.

    package demo;
    
    import org.junit.Test;
    import org.junit.rules.TemporaryFolder;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.TestContext;
    import org.springframework.test.context.TestExecutionListener;
    import org.springframework.test.context.TestExecutionListeners;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.test.util.ReflectionTestUtils;
    
    import java.io.IOException;
    
    import static junit.framework.TestCase.assertEquals;
    import static org.springframework.test.context.TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = FileRepositoryApp.class)
    @TestExecutionListeners(listeners = FileRepositoryAppTest.SetBasePath.class, mergeMode = MERGE_WITH_DEFAULTS)
    public class FileRepositoryAppTest {
    
        private static TemporaryFolder temporaryFolder = new TemporaryFolder();
    
        @Autowired
        private FileRepositoryApp.FileRepository fileRepository;
    
        @Test
        public void method() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(fileRepository.getBasePath());
            assertEquals(temporaryFolder.getRoot(), fileRepository.getBasePath());
        }
    
        @Test
        public void method1() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(fileRepository.getBasePath());
            assertEquals(temporaryFolder.getRoot(), fileRepository.getBasePath());
        }
    
        static class SetBasePath implements TestExecutionListener {
    
            @Override
            public void beforeTestExecution(TestContext testContext) throws IOException {
                temporaryFolder.create();
                if (testContext.hasApplicationContext()) {
                    FileRepositoryApp.FileRepository bean = testContext.getApplicationContext().getBean(FileRepositoryApp.FileRepository.class);
                    ReflectionTestUtils.setField(bean, "basePath", temporaryFolder.getRoot());
                }
            }
    
            @Override
            public void afterTestMethod(TestContext testContext) {
                temporaryFolder.delete();
            }
        }
    }
    

    Approach 2: Configuration properties

    Introduce a configuration properties class for your application configuration. It gives you type safety for free and we don't rely on reflection anymore.

    package demo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    
    import java.io.File;
    
    @SpringBootApplication
    public class FileRepositoryWithPropertiesApp {
    
        public static void main(String[] args) {
            SpringApplication.run(FileRepositoryWithPropertiesApp.class, args);
        }
    
        @Component
        public class FileRepository {
    
            private final FileRepositoryProperties fileRepositoryProperties;
    
            public FileRepository(FileRepositoryProperties fileRepositoryProperties) {
                this.fileRepositoryProperties = fileRepositoryProperties;
            }
    
            public File getBasePath() {
                return fileRepositoryProperties.getBasePath();
            }
        }
    
        @Component
        @ConfigurationProperties(prefix = "acme.file-repository")
        public class FileRepositoryProperties {
    
            private File basePath;
    
            public File getBasePath() {
                return basePath;
            }
    
            public void setBasePath(File basePath) {
                this.basePath = basePath;
            }
        }
    
    }
    

    Because we are using Spring's TestExecutionListener, that gets initialized before Junit rules are initialized, we are forced to manage the temporary folder in beforeTestExecution and afterTestMethod.

    package demo;
    
    import org.junit.Test;
    import org.junit.rules.TemporaryFolder;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.TestContext;
    import org.springframework.test.context.TestExecutionListener;
    import org.springframework.test.context.TestExecutionListeners;
    import org.springframework.test.context.junit4.SpringRunner;
    
    import java.io.IOException;
    
    import static junit.framework.TestCase.assertEquals;
    import static org.springframework.test.context.TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = FileRepositoryWithPropertiesApp.class)
    @TestExecutionListeners(listeners = FileRepositoryWithPropertiesTest.SetBasePath.class, mergeMode = MERGE_WITH_DEFAULTS)
    public class FileRepositoryWithPropertiesTest {
    
        private static TemporaryFolder temporaryFolder = new TemporaryFolder();
    
        @Autowired
        private FileRepositoryWithPropertiesApp.FileRepository bean;
    
        @Test
        public void method() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(bean.getBasePath());
            assertEquals(temporaryFolder.getRoot(), bean.getBasePath());
        }
    
        @Test
        public void method1() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(bean.getBasePath());
            assertEquals(temporaryFolder.getRoot(), bean.getBasePath());
        }
    
        static class SetBasePath implements TestExecutionListener {
    
            @Override
            public void beforeTestExecution(TestContext testContext) throws IOException {
                temporaryFolder.create();
                if (testContext.hasApplicationContext()) {
                    FileRepositoryWithPropertiesApp.FileRepositoryProperties bean = testContext.getApplicationContext().getBean(FileRepositoryWithPropertiesApp.FileRepositoryProperties.class);
                    bean.setBasePath(temporaryFolder.getRoot());
                }
            }
    
            @Override
            public void afterTestMethod(TestContext testContext) {
                temporaryFolder.delete();
            }
        }
    }
    

    Approach 3: Refactor your code (my favorite)

    Extract basePath into its own class and hide it behind an api. Now you don't need to poke with your application properties and a temporary folder anymore.

    package demo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    
    import java.io.File;
    
    @SpringBootApplication
    public class FileRepositoryWithAbstractionApp {
    
        public static void main(String[] args) {
            SpringApplication.run(FileRepositoryWithAbstractionApp.class, args);
        }
    
        @Component
        public class FileRepository {
    
            private final FileRepositorySource fileRepositorySource;
    
            public FileRepository(FileRepositorySource fileRepositorySource) {
                this.fileRepositorySource = fileRepositorySource;
            }
    
            public File getBasePath() {
                return fileRepositorySource.getBasePath();
            }
        }
    
        @Component
        public class FileRepositorySource {
    
            private final FileRepositoryProperties fileRepositoryProperties;
    
            public FileRepositorySource(FileRepositoryProperties fileRepositoryProperties) {
                this.fileRepositoryProperties = fileRepositoryProperties;
            }
    
            // TODO for the sake of brevity no real api here
            public File getBasePath() {
                return fileRepositoryProperties.getBasePath();
            }
        }
    
        @Component
        @ConfigurationProperties(prefix = "acme.file-repository")
        public class FileRepositoryProperties {
    
            private File basePath;
    
            public File getBasePath() {
                return basePath;
            }
    
            public void setBasePath(File basePath) {
                this.basePath = basePath;
            }
        }
    }
    

    We don't need any additional testing facility anymore and we can use @Rule on TemporaryFolder instead.

    package demo;
    
    import org.junit.Before;
    import org.junit.Rule;
    import org.junit.Test;
    import org.junit.rules.TemporaryFolder;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.mock.mockito.MockBean;
    import org.springframework.test.context.junit4.SpringRunner;
    
    import static junit.framework.TestCase.assertEquals;
    import static org.mockito.Mockito.when;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = FileRepositoryWithAbstractionApp.class)
    public class FileRepositoryWithAbstractionTest {
    
        @Rule
        public TemporaryFolder temporaryFolder = new TemporaryFolder();
    
        @MockBean
        private FileRepositoryWithAbstractionApp.FileRepositorySource fileRepositorySource;
    
        @Autowired
        private FileRepositoryWithAbstractionApp.FileRepository bean;
    
        @Before
        public void setUp() {
            when(fileRepositorySource.getBasePath()).thenReturn(temporaryFolder.getRoot());
        }
    
        @Test
        public void method() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(bean.getBasePath());
            assertEquals(temporaryFolder.getRoot(), bean.getBasePath());
        }
    
        @Test
        public void method1() {
            System.out.println(temporaryFolder.getRoot().getAbsolutePath());
            System.out.println(bean.getBasePath());
            assertEquals(temporaryFolder.getRoot(), bean.getBasePath());
        }
    
    }
    

    Approach 4: TestPropertySource

    Use Spring's TestPropertySource annotation to override properties in a test selectively. Because a Java anntotation can not have a dynamic value, you need to decide beforehand where you want to create your directory and keep in mind your test is bound to a specific operating system due to the used os path separator.

    package demo;
    
    import org.junit.After;
    import org.junit.Before;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.TestPropertySource;
    import org.springframework.test.context.junit4.SpringRunner;
    
    import java.io.IOException;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.Paths;
    
    import static demo.FileRepositoryTestPropertySourceTest.BASE_PATH;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = FileRepositoryApp.class)
    @TestPropertySource(properties = "acme.fileRepository.basePath=" + BASE_PATH)
    public class FileRepositoryTestPropertySourceTest {
    
        static final String BASE_PATH = "/tmp/junit-base-path";
    
        private Path basePath = Paths.get(BASE_PATH);;
    
        @Autowired
        private FileRepositoryApp.FileRepository fileRepository;
    
        @Before
        public void setUp() throws IOException {
            Files.deleteIfExists(basePath);
            Files.createDirectories(basePath);
        }
    
        @After
        public void after() throws IOException {
            Files.deleteIfExists(basePath);
        }
    
        @Test
        public void method() {
            System.out.println(fileRepository.getBasePath());
        }
    }