testcontainers-java icon indicating copy to clipboard operation
testcontainers-java copied to clipboard

Spring Boot Tests with JUnit5 Jupiter and Shared Containers

Open KaiStapel opened this issue 5 years ago • 47 comments

Currently, a typical test setup with Spring Boot tests and shared containers does not work anymore with JUnit 5: Using static testcontainers with Spring Boot's ApplicationContextInitializer to configure the server's ports in the Spring context.

The problem is that the Spring application context gets initialized during Jupiter extension's post processing while testcontainers are started during before all callbacks. The latter happens after post processing.

See https://github.com/junit-team/junit5/blob/c9ae6e261550481362f17d21e88137a8c71fc7c8/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java#L184 vs. https://github.com/junit-team/junit5/blob/c9ae6e261550481362f17d21e88137a8c71fc7c8/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java#L189

One approach to solve this could be to start the testcontainers also during post processing (TestInstancePostProcesso#postProcessTestInstancer)

Then the only remaining problem would be to ensure that the TestcontainersExtension always gets created before the SpringExtension.

Problematic Example

@SpringBootTest
@ContextConfiguration(initializers = {Initializer.class})
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JupiterSharedTestcontainersTests {

  @Container
  public static JdbcDatabaseContainer postgreSQLContainer = new PostgreSQLContainer();

  @Test
  public void testTomatoes() {
    assertTrue("tomatoes".equals("tomatoes"));
  }
  
  public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
      TestPropertyValues.of(
          "spring.datasource.url=jdbc:postgresql://"
              + postgreSQLContainer.getContainerIpAddress()
              + ":" + postgreSQLContainer.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)
              + "/db_name"
      ).applyTo(configurableApplicationContext.getEnvironment());
    }
  }

}

What do you think?

KaiStapel avatar Jan 26 '20 17:01 KaiStapel

Hi, did you try to put @TestInstance(TestInstance.Lifecycle.PER_CLASS) annotation on your test class? This should allow you to create BeforeAll/AfterAll(which is actually used by testcontainers extension) as non-static methods.

yholia avatar Jan 27 '20 17:01 yholia

Hi, yes I did. That is exactly how I came accross this problem.

KaiStapel avatar Jan 27 '20 18:01 KaiStapel

I updated the initial request with an example that shows the problem.

KaiStapel avatar Jan 29 '20 16:01 KaiStapel

Two possible workarounds I can think of:

  1. [if makes sense] Use the Singleton Container Pattern.
  2. Explicitly start the container in 'initialize' and make it a reusable container as explained here if applicable (will not currently work for a docker-compose container for instance).

fullkomnun avatar Mar 08 '20 11:03 fullkomnun

it doesn't have to be marked as reusable if you only need to use the same container between the tests. Reusable Containers feature is for reusing containers between the test runs (think running mvn test twice)

bsideup avatar Mar 08 '20 12:03 bsideup

@KaiStapel you can remove @Container from postgreSQLContainer and call postgreSQLContainer.start() in Initializer.

Also, in your particular example, you don't need all of it, just use the JDBC URL support: https://www.testcontainers.org/modules/databases/#database-containers-launched-via-jdbc-url-scheme

bsideup avatar Mar 08 '20 12:03 bsideup

@fullkomnun and @bsideup Thanks for proposing these workarounds. We were already using the singleton container pattern to make it work.

Still, imo, this ticket makes sense to fix the TestcontainersExtension to work for shared containers with JUnit5 or at least mention its limitations in the documentation.

KaiStapel avatar Mar 09 '20 10:03 KaiStapel

Hi all. I would support @KaiStapel's position here. I ran down his same path trying to add @TestInstance(TestInstance.Lifecycle.PER_CLASS) to class this morning. The setup is somewhat expensive. It would be nice to execute it once.

The order dependence between the extensions is a big down side, but I don't see an easy way around that.

byrd-railroad19 avatar Mar 26 '20 16:03 byrd-railroad19

Can you try https://spring.io/blog/2020/03/27/dynamicpropertysource-in-spring-framework-5-2-5-and-spring-boot-2-2-6 ?

bsideup avatar May 22 '20 21:05 bsideup

@bsideup I tried your suggestion and still get the same exception. The test continues and refreshes the context after waiting for db to be ready, so it succeeds. But it would be nice if the context were initialized for the first time only once the db is ready.

yanivnahoum avatar May 25 '20 10:05 yanivnahoum

@yanivnahoum

But it would be nice if the context were initialized for the first time only once the db is ready.

That's how it works already. Please check your setup (and share a reproducer otherwise)

bsideup avatar May 25 '20 10:05 bsideup

Thanks @bsideup, here's an example using Spring Boot 2.3.0 & Testcontainers 1.14.2

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class MySqlTest {

    @Container
    private static final MySQLContainer<?> mySqlContainer = new MySQLContainer<>("mysql:8.0.20");

    static {
        // mySqlContainer.start();
    }

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

    @Test
    void test(@Autowired Environment env) {
        System.out.println("#test - spring.datasource.url=" + env.getProperty("spring.datasource.url"));
    }
}

This results in an exception with the following root cause:

java.lang.IllegalStateException: Mapped port can only be obtained after the container is started
	at org.testcontainers.shaded.com.google.common.base.Preconditions.checkState(Preconditions.java:174)
	at org.testcontainers.containers.ContainerState.getMappedPort(ContainerState.java:141)
	at org.testcontainers.containers.MySQLContainer.getJdbcUrl(MySQLContainer.java:73)
	at org.springframework.test.context.support.DynamicValuesPropertySource.getProperty(DynamicValuesPropertySource.java:43)
	at org.springframework.core.env.PropertySourcesPropertyResolver.getProperty(PropertySourcesPropertyResolver.java:85)
	at org.springframework.core.env.PropertySourcesPropertyResolver.getProperty(PropertySourcesPropertyResolver.java:62)
	at org.springframework.core.env.AbstractEnvironment.getProperty(AbstractEnvironment.java:535)
	at org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration$EmbeddedDatabaseCondition.hasDataSourceUrlProperty(DataSourceAutoConfiguration.java:147)
	at org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration$EmbeddedDatabaseCondition.getMatchOutcome(DataSourceAutoConfiguration.java:130)
	at org.springframework.boot.autoconfigure.condition.SpringBootCondition.matches(SpringBootCondition.java:47)

after which the context loads again, the properties are set correctly and the test passes

yanivnahoum avatar May 25 '20 11:05 yanivnahoum

@yanivnahoum please share (or update) full example (with imports).

bsideup avatar May 25 '20 11:05 bsideup

@bsideup I edited the comment above

yanivnahoum avatar May 25 '20 11:05 yanivnahoum

@yanivnahoum I just tried your example and got a successful result:

#test - spring.datasource.url=jdbc:postgresql://localhost:33492/test?loggerLevel=OFF

Have you tried running it? Can you share a full project that reproduces the issue?

bsideup avatar May 25 '20 12:05 bsideup

@bsideup you switched to Postgres? Anyway, in my example, the test also succeeds and prints the url, but only after the exception stated above occurs. Does the same happen to you?

yanivnahoum avatar May 25 '20 12:05 yanivnahoum

@yanivnahoum I did (I only had postgres module on classpath), but it does not change anything

Anyway, in my example, the test also succeeds and prints the url, but only after the exception stated above occurs. Does the same happen to you?

No, I did not get any exception in the logs.

bsideup avatar May 25 '20 12:05 bsideup

@bsideup then I'll create a repo with just that example and share it

yanivnahoum avatar May 25 '20 12:05 yanivnahoum

Please note that this ticket is only about the problem when adding @TestInstance(TestInstance.Lifecycle.PER_CLASS) on the test class. Without it everything seems to work fine and as expected (including only starting the container once).

KaiStapel avatar May 25 '20 13:05 KaiStapel

While trying to create a minimal example to reproduce the issue, I found the culprit: org.springframework.cloud.contract.wiremock.WireMockTestExecutionListener from spring-cloud-contract-wiremock. It forces the application context to load before the tc db is ready.... @bsideup thanks for your help!

yanivnahoum avatar May 25 '20 15:05 yanivnahoum

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If you believe this is a mistake, please reply to this comment to keep it open. If there isn't one already, a PR to fix or at least reproduce the problem in a test case will always help us get back on track to tackle this.

stale[bot] avatar Aug 23 '20 19:08 stale[bot]

This is an important issue - any chance it might have been fixed or addressed in a more recent release I am seeing it with SpringBoot 2.3.0 TestContainers 1.14.3

using both

  1. PostgreSQLContainer
  2. DockerComposeContainer

frankjkelly avatar Aug 24 '20 14:08 frankjkelly

@sbrannen can it be an issue with Spring Boot's Lifecycle and not Testcontainers' extension?

bsideup avatar Nov 11 '20 10:11 bsideup

Any updates on this ?

driverpt avatar Jan 05 '21 15:01 driverpt

I had a similar problem with a JUnit 5 extension I wrote and I found out when tests are run in TestInstance.Lifecycle.PER_CLASS Spring's application context will be loaded before the org.junit.jupiter.api.extension.BeforeAllCallback#beforeAll is called. So I solved the problem by moving my logic to org.junit.jupiter.api.extension.TestInstancePostProcessor#postProcessTestInstance.

Does it also make sense to start the "Shared Containers" on postProcessTestInstance?

amirmv2006 avatar Jan 10 '21 11:01 amirmv2006

I would say this needs to be solved with a TestExecutionListener

Create a Spring Boot Starter spring-boot-starter-testcontainers with the Following dependencies:

  • org.testcontainers:testcontainers
  • org.testcontainers:junit-jupiter

Where the TestContainersTestExecutionListener needs to have something like this:

  @Override
  public void beforeTestClass(final TestContext testContext) throws Exception {
    super.beforeTestClass(testContext);
    Arrays.stream(testContext.getTestClass().getFields())
        .filter(
            Predicate.not(field -> Objects.isNull(field.getAnnotation(Container.class))))
        .filter(field -> field.getType().isAssignableFrom(org.testcontainers.containers.Container.class))
        .forEach(field -> startMethod.invoke(field));
  }

And introduce something like @AutoConfigureTestContainers

driverpt avatar Jan 12 '21 14:01 driverpt

I think that'd be a spring-specific solution. There's a already a junit 5 annotation @Testcontainers which is loading TestContainer's junit extension TestcontainersExtension and this extension is doing the handling of containers. So if this extension is 'fixed' then TestContainers will remain spring independent

amirmv2006 avatar Jan 12 '21 21:01 amirmv2006

@bsideup your thoughts?

amirmv2006 avatar Feb 25 '21 14:02 amirmv2006

Used an extension coupled with a context customizer:


public class DatabaseExtension implements BeforeAllCallback, AfterAllCallback {
    private static final ThreadLocal<CustomContainer> THREAD_CONTAINER = new ThreadLocal<>();

    @Override
    public void beforeAll(ExtensionContext context) {
        // This can come from a custom extension, or a hard coded default if that makes sense in your project
        String imageName = "";

        // Custom container can be a vanilla MySQLContainer, or something custom if you need a specific JDBC URL, etc
        CustomContainer container = new CustomContainer(DockerImageName.parse(imageName).asCompatibleSubstituteFor(MySQLContainer.NAME));
        THREAD_CONTAINER.set(container);

        container.start();
    }

    @Override
    public void afterAll(ExtensionContext context) {
        CustomContainer container = THREAD_CONTAINER.get();
        if (container != null) {
            // You should clear the thread local at this stage
            container.stop();
        }
    }

    // This class should be added to META-INF/spring.factories
    public static class CustomizerFactory implements ContextCustomizerFactory {
        @Override
        public ContextCustomizer createContextCustomizer(Class<?> testClass, List<ContextConfigurationAttributes> configAttributes) {
            return (context, mergedConfig) -> {
                // This is where we use the thread local since I couldn't figure out how to get the container reference across to Spring
                CustomContainer container = THREAD_CONTAINER.get();
                if (container != null) {
                    TestPropertyValues.of("spring.datasource.url=" + container.getJdbcUrl(),
                            "spring.datasource.username=" + container.getUsername(),
                            "spring.datasource.password=" + container.getPassword())
                            .applyTo(context.getEnvironment());
                }
            };
        }
    }

The test class can then use @ExtendWith({DatabaseExtension.class, SpringExtension.class}) without any additional changes.

nickcaballero avatar Apr 02 '21 14:04 nickcaballero

it doesn't have to be marked as reusable if you only need to use the same container between the tests. Reusable Containers feature is for reusing containers between the test runs (think running mvn test twice)

I am very surprised about this statement @bsideup . In the documentation it says https://www.testcontainers.org/test_framework_integration/junit_5/ that:

  • an static @Container is reused across whole test class
  • a dynamic @Container is not reused at all

So if reuse is for reusing across JVMs, what is the setup to reuse the container across test classes (/ JVM-wise?)

nightswimmings avatar Feb 11 '22 10:02 nightswimmings