Simplify Local Development with Testcontainers
In this blog post, I will explain how you can you can reuse your existing testing infrastructure based on Testcontainers for local development.
In my previous blog posts ( part-1, part-2), I have explained how we can use docker-compose to start containers and use them for local development.
If you are using Testcontainers library for your integration tests, with small refactorting of your code, you can start the required containers with Testcontainers for local development with out using the docker compose file.
For demonstration purpose, I am going to use the rabbitmq-producer-testcontainers application from the GitHub.
I am going to refactor the integration test classes so that we can use can move the containers starting logic to common class which can be used by both local development and integration tests.
Step 1) Moving all the container initializations to common classes
Referenced repo has following code for running the integration tests.
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
@Slf4j
public class RabbitMQProducerControllerIT {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RabbitAdmin rabbitAdmin;
@Autowired
private RabbitMQProducerController rabbitMQProducerController;
@Autowired
private TestRestTemplate testRestTemplate;
static RabbitMQContainer rabbitMQContainer ;
static {
rabbitMQContainer = new RabbitMQContainer("rabbitmq:3.10.6-management-alpine")
.withStartupTimeout(Duration.of(100, SECONDS));
rabbitMQContainer.start();
}
Code language: Java (java)
Let’s refactor the class so that we can centralize the container initialization logic. We are going to use this class for running integration tests and local development.
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Slf4j
@ContextConfiguration(initializers = AbstractIntegrationTest.Initializer.class)
public abstract class AbstractIntegrationTest {
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
static final RabbitMQContainer rabbitMQContainer = new RabbitMQContainer("rabbitmq:3.10.6-management-alpine")
.withStartupTimeout(Duration.of(100, SECONDS))
.withEnv("JAVA_OPTS", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8005");
public static Map<String, String> getProperties() {
Startables.deepStart(Stream.of(rabbitMQContainer)).join();
return Map.of(
"spring.rabbitmq.host",rabbitMQContainer.getHost(),
"spring.rabbitmq.port", rabbitMQContainer.getAmqpPort().toString()
}
public void initialize(ConfigurableApplicationContext context) {
ConfigurableEnvironment env = context.getEnvironment();
env.getPropertySources().addFirst(new MapPropertySource("testcontainers", (Map) getProperties()));
}
}
Code language: Java (java)
In our actual integration test we simply extend the abstract class
@Slf4j
public class RabbitMQProducerControllerIT extends AbstractIntegrationTest{
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RabbitAdmin rabbitAdmin;
@Autowired
private RabbitMQProducerController rabbitMQProducerController;
@Autowired
private TestRestTemplate testRestTemplate;
....
}
Code language: Java (java)
Step 2) Refactoring the Spring Boot Main class.
We can refactor Spring Boot Main class a little and expose the application builder via createSpringApplication method
@SpringBootApplication
public class SpringbootRabbitmqProducerApplication {
public static void main(String[] args) {
createSpringApplication().run(args);
}
public static SpringApplication createSpringApplication() {
return new SpringApplication(SpringbootRabbitmqProducerApplication.class);
}
Code language: Java (java)
Step 3) Write a Local Development class
Next we write local development class which will start application and required containers
public class LocalDevelopment {
public static void main(String[] args) {
SpringApplication application = SpringbootRabbitmqProducerApplication.createSpringApplication();
application.addInitializers(new AbstractIntegrationTest.Initializer());
application.run(args);
}
}
Code language: Java (java)
Now If you run the Localdeveloment class you can start application and test it from your local machine.
Debugging Applications
When we use docker-compose, we run the containers on dedicated port, so we can view the Data in database using database client and message posted to RabbitMQ using its management dashboard.
One disadvantage of running containers with Testcontainers library is every time we start containers they use the random port which makes it difficult to debug application or view the data
To overcome the random port problem, we can use the Proxy service to expose containers through static post.
Let’s see how it can be done. There are 2 libraries which you can use.
Option1 ) Using JavaNioTcpProxy
Add following library to pom xml
<dependency>
<groupId>com.github.terma</groupId>
<artifactId>javaniotcpproxy</artifactId>
<version>1.5</version>
</dependency>
Code language: Java (java)
In your Application initializer class, you start proxy server and map the random port to fixed port.
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
static final RabbitMQContainer rabbitMQContainer = new RabbitMQContainer("rabbitmq:3.10.6-management-alpine")
.withStartupTimeout(Duration.of(100, SECONDS))
.withEnv("JAVA_OPTS", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:8005");
public void initialize(ConfigurableApplicationContext context) {
ConfigurableEnvironment env = context.getEnvironment();
env.getPropertySources().addFirst(new MapPropertySource("testcontainers", (Map) getProperties()));
TcpProxyConfig config = new StaticTcpProxyConfig(
5005,
rabbitMQContainer.getHost(),
rabbitMQContainer.getMappedPort(15672)
);
config.setWorkerCount(1);
TcpProxy tcpProxy = new TcpProxy(config);
tcpProxy.start();
}
}
Code language: Java (java)
Based on above code, RabbitMQ management console is available at port 5005 when you start the application using LocalDevelopment class.
Option 2) Using TcpTunnel
Add library to pom xml
<dependency>
<groupId>net.kanstren.tcptunnel</groupId>
<artifactId>tcptunnel</artifactId>
<version>1.2.0</version>
</dependency>
Code language: Java (java)
In your Application initializer class, you start proxy server and you map the random port to fixed port.
public abstract class AbstractIntegrationTest {
public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
...
public void initialize(ConfigurableApplicationContext context) {
ConfigurableEnvironment env = context.getEnvironment();
env.getPropertySources().addFirst(new MapPropertySource("testcontainers", (Map) getProperties()));
Params params = new Params(5006, rabbitMQContainer.getHost(), rabbitMQContainer.getMappedPort(15672));
//we want to use the captured data in testing, so enable logging the tunnel data in memory with buffer size 8092 bytes
params.enableInMemoryLogging(8092);
InMemoryLogger upLogger = params.getUpMemoryLogger();
InMemoryLogger downLogger = params.getDownMemoryLogger();
//this is how we actually start the tunnel
net.kanstren.tcptunnel.Main main = new net.kanstren.tcptunnel.Main(params);
main.start();
}
}
}
Code language: Java (java)
Based on above code, RabbitMQ management console is available at port 5006 when you start the application using LocalDevelopment class.
You can download the source code for the blog post from GitHub
References
https://bsideup.github.io/posts/local_development_with_testcontainers/