###### tags: `java` TestContainers for java === Introduction --- 假設類unit test的testcase會access到外部service(Database or Redis),會很難測,因為你的返回結果會直接的受外部服務影響,導致你的單元測試可能今天會過、但明天就過不了 Testcontainers for Java is a Java library which support JUnit tests. TestContainers可以於測試時透過模擬一個假的外部service來返回的data,而不會真正去 access 外部 service --- Related Work --- #### TestContainer: For example, if we use TestContainer to start a Postgresql server in container, then we can get data from this container that is just for testing. The method of access database is same as how you react with real server. --- #### Other solutions: - Mock Test Library: Mockito, JMockit, EasyMock... - H2 Database: In-memory databases for testing Problems of these tools. For example, SQL syntax may have some differents between real relational database and these simulation tools. --- Environment Setup --- - Install Docker - Install Maven package - testcontainers ```xml <dependencies> ... <!-- testcontainers --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>1.17.5</version> <scope>test</scope> </dependency> <!-- testcontainers postgresql module --> <!-- Recommand to use this dependency if you use postgresql --> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.17.5</version> <scope>test</scope> </dependency> ... </dependencies> ``` --- How to use TestContainers --- This tutorial has two example - TestContainer with Redis - TestContainer with Postgresql ### TestContainer with Redis 1. Container setup Start the container then generate properties dynamically. ###### test/java/com.example.demo/controller/TestContainerSetup.java ```java= public abstract class TestContainerSetup { static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:5.0.5")) .withExposedPorts(6379); @DynamicPropertySource static void redisProperties(DynamicPropertyRegistry registry) { redis.start(); registry.add("spring.redis.host", redis::getHost); registry.add("spring.redis.port", redis::getFirstMappedPort); } } ``` Can use `DockerImageName.parse("redis:5.0.5")` to set image name. `GenericContainer` settings: - `withExposedPorts`: the port of Redis running in container. - `withEnv` - `withCommand` - `withClasspathResourceMapping`: Map a resource (file or directory) on the classpath to a path inside the container. --- 2. Configurate redis connection. We have to setup redis connection manually, because we will use two different redis servers. If we use [simple way]() to setup redis, without specify which `RedisConnectionFactory` we want(at line 24), spring boot will automatically pass the same `RedisConnectionFactory` to both `@Configuration` and`@TestConfiguration`. ###### test/java/com.example.demo/config/TestContainerRedisConfig.java ```java= @TestConfiguration @EnableCaching public class TestContainerRedisConfig { @Data static public class RedisProperty { private String host; private int port; } // load properties, that we set in the first setp. @ConfigurationProperties(prefix = "spring.redis") @Bean("TestRedisSource") public RedisProperty redisSource() { return new RedisProperty(); } // new `RedisConnectionFactory` by `Properties` @Bean(name = "TestRedisConnectionFactory") public RedisConnectionFactory userRedisConnectionFactory(@Qualifier("TestRedisSource") RedisProperty redisProperty) { RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); redisConfiguration.setHostName(redisProperty.getHost()); redisConfiguration.setPort(redisProperty.getPort()); return new LettuceConnectionFactory(redisConfiguration); } // must setting this bean name or change the functoin name, or you will get cannot overwrite `RedisConnectionFactory` error message. @Bean("TestRedisTemplate") RedisTemplate redisTemplate; public RedisTemplate<String, Object> redisTemplate(@Qualifier("TestRedisConnectionFactory") RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate(); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setConnectionFactory(connectionFactory); return redisTemplate; } } ``` --- 3. Write a unit test. This testcase will call [the api `/setParams`](#####hint) to set key = "aaa" and value = "bbb" in Redis server. ###### test/java/com.example.demo/controller/RedisTestControllerTest.java ```java= @SpringBootTest @Import(TestContainerRedisConfig.class) class RedisTestControllerTest extends TestContainerSetup { @Autowired TestController testController; @Test void setParam() { String actual = testController.setParams("aaa", "bbb"); String expected = "success"; assertEquals(expected, actual); } } ``` - `@Import` is used to load `@TestConfiguration` while running testcase. - Here our testcase inherit `class TestContainerSetup` this will start conatiner and setup properties while running this testcase. ##### The implementation of `testController.setParams()`. ```java @RestController public class TestController { ... @ResponseBody @PostMapping(value = "/setParams") public String setParams(@RequestParam("key") String key, @RequestParam("value") String value) { redisUtil.set(key, value); return "success"; } ... } ``` --- #### Result When start running the testcase, container will start. ![](https://i.imgur.com/HigvG6L.png) --- If you set a breakpoint, then connect to redis container. As you can see, the api works with the redis container. ![](https://i.imgur.com/iDbnj2t.png) --- ### TestContainer with Postgresql Setup Testcontainer of Postgresql server is samiliar with Redis. 1. Container setup Start the container then generate properties dynamically. Testcontainer provide a module of `postgresql` to easily setup a postgresql container. > `PostgreSQLContainer` will appear after you install the module `org.testcontainers.postgresql`. > To see more modules that `TestContainers` support. [click here](https://www.testcontainers.org/modules/databases/) ###### test/java/com.example.demo/controller/TestContainerSetup.java ```java= public abstract class TestContainerSetup { static PostgreSQLContainer db = (PostgreSQLContainer) new PostgreSQLContainer("postgres:15.1") .withInitScript("testing/sql/f_user.sql"); @DynamicPropertySource static void dbProperties(DynamicPropertyRegistry registry) { db.start(); registry.add("spring.datasource.jdbc-url", db::getJdbcUrl); registry.add("spring.datasource.username", db::getUsername); registry.add("spring.datasource.password", db::getPassword); registry.add("spring.datasource.driver-class-name", db::getDriverClassName); } } ``` Can use contructor `(PostgreSQLContainer) new PostgreSQLContainer("postgres:15.1")` to set image name. `PostgreSQLContainer` settings: - `.withInitScript("testing/sql/f_user.sql")` can initial database by executing sql commands. --- 2. Initial Database ```sql= CREATE TABLE public.f_user ( fid int NOT NULL, username varchar(100) NOT NULL ); INSERT INTO public.f_user (fid, username) VALUES (14449, 'shanehuang'); ``` --- 3. Configurate postgresql connection. Setup configuration of multiple databases. > In this case we already use multiple database connections so just copy codes from `@Configuration` and change the name of `@ConfigurationProperties` and `@Bean` name. After that Spring Boot can identify different `HikariDataSource`. ###### test/java/com.example.demo/config/TestContainerDatabaseConfig.java ```java= @TestConfiguration @MapperScan( basePackages = TestContainerDatabaseConfig.DAO_PACKAGE, sqlSessionFactoryRef = TestContainerDatabaseConfig.SESSION_FACTORY ) public class TestContainerDatabaseConfig { static final String DAO_PACKAGE = "com.example.demo.dao.exg"; static final String DATASOURCE = "exgTestDS"; static final String SESSION_FACTORY = "exgTestSessionFac"; static final String TRANSACTION_MANAGER = "exgTestTransactionManager"; @Value("${mybatis.config-location}") private String configLocation; @Bean(name = DATASOURCE) @ConfigurationProperties(prefix = "spring.datasource") // load properties, that we set in the first setp. public HikariDataSource dataSource() { return new HikariDataSource(); } @Bean(name = TRANSACTION_MANAGER) public DataSourceTransactionManager transactionManager(@Qualifier(DATASOURCE) HikariDataSource dataSource) { return new DataSourceTransactionManager(dataSource); } @Bean(name = SESSION_FACTORY) public SqlSessionFactory sqlSessionFactory(@Qualifier(DATASOURCE) HikariDataSource dataSource) throws Exception { final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); sessionFactory.setConfigLocation(new PathMatchingResourcePatternResolver().getResource(configLocation)); return sessionFactory.getObject(); } } ``` --- 4. Write a unit test. This testcase will call api's function to select all field of `f_user` table where username is "shanehuang" from database, then check return value. ###### test/java/com.example.demo/controller/DBTestControllerTest.java ```java= @SpringBootTest @Import(TestContainerDatabaseConfig.class) class DBTestControllerTest extends TestContainerSetup { @Autowired private DBTestController dbTestController; @Test void runGetUser() { String actual = dbTestController.getUser(); String expected = "shanehuang"; Assertions.assertEquals(expected, actual); } } ``` --- #### Result There you go. You can verify the result same as [Redis section](####Result) by your self. --- Conclusion --- Testcontainer can help you setup a database instance for execting unit test, without access database in your production enviroment by using container technology. --- ref: - officaial website: https://www.testcontainers.org/