javaspringtestingtestcontainers

How would I structure my annotation and Spring Configurations so that I can use an annotation to set up a TestContainer test without an abstract class


I want to be able to simply do

@MyTestConfig
class MyTests {
  @Autowired SomeJpaRepo repo;
  @Test
  void findAll() {
    assertThatCode(repo::findAll).doesNotThrowAnyExceptions();
  }
}

What I started was to do the annotation

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainers
public @interface MyTestConfig {
}

With that the following test works give

@MyTestConfig
class MyTests {
  @Container
  private static final MySQLContainer<?> MYSQL_CONTAINER =
      new MySQLContainer<>(
          DockerImageName.parse("library/mariadb")
              .asCompatibleSubstituteFor("mysql"));

  @DynamicPropertySource
  static void mysqlProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl);
    registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername);
    registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword);
  }
  @Autowired SomeJpaRepo repo;
  @Test
  void findAll() {
    assertThatCode(repo::findAll).doesNotThrowAnyExceptions();
  }
}

However, I am unable to get that TestContainer configuration out to a separate file that is managed by @MyTestConfig, the closest thing that worked which I wanted to avoid if possible is creating an abstract test class

abstract class AbstractMyTest {
  @Container
  private static final MySQLContainer<?> MYSQL_CONTAINER =
      new MySQLContainer<>(
          DockerImageName.parse("library/mariadb")
              .asCompatibleSubstituteFor("mysql"));

  @DynamicPropertySource
  static void mysqlProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl);
    registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername);
    registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword);
  }
}

I am thinking that it may be something along the lines of creating a JUnit extension class rather than utilizing Spring annotations.

Just a note I'm still on Spring Boot 2.7.18, so I don't have the @ServiceContainer annotation.


Solution

  • To run a test container outside of the test class without using an abstract test class, there are several approaches:

    1. JUnit extension approach:

    One of the disadvantages of this approach is that you cannot use @Testcontainers, @Container or @DynamicPropertySource annotations and have to start container manually:

    import org.junit.jupiter.api.extension.BeforeAllCallback;
    import org.junit.jupiter.api.extension.ExtensionContext;
    import org.testcontainers.containers.MySQLContainer;
    import org.testcontainers.utility.DockerImageName;
    
    public class MySqlTestContainerExtension implements BeforeAllCallback {
    
        private static final MySQLContainer<?> MYSQL_CONTAINER =
                new MySQLContainer<>(
                        DockerImageName.parse("library/mariadb")
                                .asCompatibleSubstituteFor("mysql"));
    
        @Override
        public void beforeAll(ExtensionContext context)  {
            if (!MYSQL_CONTAINER.isRunning()) {
                MYSQL_CONTAINER.start();
    
                System.setProperty("spring.datasource.url", MYSQL_CONTAINER.getJdbcUrl());
                System.setProperty("spring.datasource.username", MYSQL_CONTAINER.getUsername());
                System.setProperty("spring.datasource.password", MYSQL_CONTAINER.getPassword());
            }
        }
    }
    

    and after that you have to add this extension like any other extension using @ExtendWith above your test class or custom annotation:

    import org.junit.jupiter.api.extension.ExtendWith;
    
    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Inherited
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    @ExtendWith(MySqlTestContainerExtension.class) // custom extension
    public @interface MyTestConfig {
    }
    
    1. Database test containers launched via JDBC URL scheme approach:

    As stated in the documentation, this approach eliminates the need to define a container in Java code. The only requirement is to set the correct JDBC URL in the test properties by adding tc: to the original URL, like:

    spring:
      datasource:
        username: username
        password: password
        url: jdbc:tc:mysql:latest:///databasename
      jpa:
        database-platform: org.hibernate.dialect.MySQL8Dialect
    

    However, if we want to use some other image name, like library/mariadb as compatible substitute for mysql, we have to:

    package com.example.somepackage;
    
    import org.testcontainers.utility.DockerImageName;
    import org.testcontainers.utility.ImageNameSubstitutor;
    
    public class ExampleImageNameSubstitutor extends ImageNameSubstitutor {
    
        private final DockerImageName imageName = DockerImageName.parse("library/mariadb")
                .asCompatibleSubstituteFor("mysql");
    
    
        @Override
        public DockerImageName apply(DockerImageName original) {
            if (original.asCanonicalNameString().contains("mysql")) {
                return imageName;
            }
    
            return original;
        }
    
        @Override
        protected String getDescription() {
            return "example image name substitutor";
        }
    }
    
    

    image.substitutor=com.example.somepackage.ExampleImageNameSubstitutor

    1. TestConfiguration approach:

    If we use spring-boot 3.1 or higher, we can define a bean of testcontainer and mark it with @ServiceConnection annotation:

    import org.springframework.boot.test.context.TestConfiguration;
    import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
    import org.springframework.context.annotation.Bean;
    import org.testcontainers.containers.MySQLContainer;
    import org.testcontainers.utility.DockerImageName;
    
    @TestConfiguration(proxyBeanMethods = false)
    public class MySqlContainerConfiguration {
    
        @Bean
        @ServiceConnection
        public MySQLContainer<?> mySqlContainer() {
            DockerImageName imageName = DockerImageName.parse("library/mariadb").asCompatibleSubstituteFor("mysql");
            return new MySQLContainer<>(imageName);
        }
    }
    

    Summary:

    As we found out, the test configuration approach is not suitable for you, since you are using version 2.7.18, so there are two options left - JUnit extension and Database containers launched via JDBC URL scheme.

    Personally I would choose the Junit extension, since the configuration of the test container is more visible and no need to write a custom ImageNameSubstitutor, however in some cases, the option of launching container via the JDBC URL looks even more elegant, reducing the container creation boilerplate code.