Một trong những phần khó nhất của Integration Test là giả lập tương tác với các hệ thống bên thứ ba ví dụ như Database, HTTP Servers, ... và Caches. Tưởng tượng bạn đang sử dụng database PostgreSQL với ứng dụng SpringBoot, trong khi chạy integration test trên CI/CD pipeline chúng ta lại thường sử dụng in-memory database như H2. Việc sử dụng H2 có một vài nhược điểm sau:
- Vì H2 không phải là production database nên không mang lại độ tin cậy cao
- Nếu bạn đang viết bất kỳ native query nào sử dụng production database, bạn sẽ không thể test nó
Chúng ta có thể giải quyết các vấn đề trên bằng cách sử dụng thư viện java Testcontainers
Testcontainers là một thư viện java hỗ trợ Junit Tests, cung cấp các phiên bản lightweight, throwaway của các common instance như database, web browser hoặc bất kỳ thứ gì có thể chạy trên Docker container.
Testcontainers có thể giúp các loại test sau trở lên dễ dàng hơn:
- Data access layer integration tests
- Application integration tests
- UI/Acceptance tests
Trong bài viết này tôi sẽ hướng dẫn cho các bạn cách đơn giản hóa việc integration test khi database sử dụng testcontainers.
1. Required Software
- JDK 1.8+
- JUnit 5
- Docker
- Springboot
Note : Testconatiners cũng hỗ trợ cả Junit 4.
2. Thêm Testcontainers dependency
Bạn có thể thêm phụ thuộc testcontainers theo hai cách sau:
2.1. Sử dụng BOM để thêm phụ thuộc vào file pom.xml
Lợi ích của việc sử dụng BOM là tránh được việc phải chỉ định version cụ thể cho từng phụ thuộc
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> ...... <properties> <java.version>11</java.version> <testcontainers.version>1.16.2</testcontainers.version> </properties> <dependencies> ..... <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers-bom</artifactId> <version>${testcontainers.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> .... </project>
2.2. Thêm phụ thuộc trực tiếp
<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.16.2</version> <scope>test</scope>
</dependency>
<dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <version>1.16.2</version> <scope>test</scope>
</dependency>
<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.16.2</version> <scope>test</scope>
</dependency>
Nếu bạn đang sử dụng PostgreSQL trong ứng dụng của mình, bạn cũng sẽ cần thêm phụ thuộc của JDBC driver vào file pom
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope>
</dependency>
3. Sử dụng Testcontainers trong Integration Test
Chúng ta có thể khởi động các container sử dụng Testcontainers theo 3 cách:
- Sử dụng Special JDBC URL (Cái này chỉ dành riêng cho database container)
- Sử dụng annotation @Container (JUnit 5) hoặc @ClassRule (JUnit 4)
- Manual container starting
3.1. Sử dụng Special JDBC URL
Điều này chỉ dành riêng cho cơ sở dữ liệu, nếu bạn đang sử dụng thêm những container khác thì tốt hơn nên sử dụng cách 2 hoặc 3
Bắt đầu với một vài integration test cases:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
public class BaseIT { @Autowired protected TestRestTemplate testRestTemplate ; }
public class DepartmentControllerIT extends BaseIT { @Test @Sql({ "/import.sql" }) public void testGetDepartmentById() { ResponseEntity<Department> response = testRestTemplate.getForEntity( "/department/{id}",Department.class,100); Department dept = response.getBody(); assertEquals(100,dept.getId()); assertEquals("HR", dept.getName()); } }
public class EmployeeControllerIT extends BaseIT { @Test @Sql({ "/import.sql" }) public void testCreateEmployee() { Department dept = new Department(); dept.setId(100); Employee emp = new Employee(); emp.setFirst_name("abc"); emp.setLast_name("xyz"); emp.setDepartment(dept); emp.setBirth_date(LocalDate.of(1980,11,11)); emp.setHire_date(LocalDate.of(2020,01,01)); emp.setGender(Gender.F); ResponseEntity<Employee> response = testRestTemplate.postForEntity( "/employee", emp, Employee.class); Employee employee = response.getBody(); assertNotNull(employee.getId()); assertEquals("abc", employee.getFirst_name()); } @Test @Sql({ "/import.sql" }) public void testGetEmployeeById() { ResponseEntity<Employee> response = testRestTemplate.getForEntity( "/employee/{id}",Employee.class,100); Employee employee = response.getBody(); assertEquals(100,employee.getId()); assertEquals("Alex", employee.getFirst_name()); } }
Bạn có thể khởi tạo một database container mà không cần bất kỳ dòng code nào với special JDBC url
Bạn cần thêm vào file application.properties trong thư mục test resources các thuộc tính sau:
spring.datasource.url=jdbc:tc:postgresql:13.2:////<DatabaseName>
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
Nếu bạn cần run init script trong khi khởi động database bạn có thể thêm url như sau:
spring.datasource.url=jdbc:tc:postgresql:13.2:////<DatabaseName>?TC_INITSCRIPT=somepath/init_script.sql
TH scripts nằm trong đường dẫn file
spring.datasource.url=jdbc:tc:postgresql:13.2:////<DatabaseName>?TC_INITSCRIPT=file:src/main/resources/init_script.sql
Với MySQL bạn có thể sử dụng URL sau
jdbc:tc:mysql:5.7.34:///databasename
Giờ hãy chạy integration test với command sau:
mvn clean verify
3.2. Sử dụng @Container
Để sử dụng annotation Container bạn cần thêm chú tích @Testcontainers vào test class.
Thêm đoạn code sau vào test class:
@Container
public PostgreSQLContainer<?> postgresDB = new PostgreSQLContainer<>("postgres:13.2").withDatabaseName("eis");
Nếu đang sử dụng JUnit4
@ClassRule
public PostgreSQLContainer<?> postgresDB = new PostgreSQLContainer<>("postgres:13.2").withDatabaseName("eis");
Bạn cũng có tạo một lớp base test để tránh việc phải thêm các đoạn khởi tạo container vào tất cả các test class:
Chú ý: đoạn code bên dưới chỉ áp dùng cho Junit 5 và springboot version >= 2.2.6
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@DirtiesContext
public class BaseIT { @Autowired protected TestRestTemplate testRestTemplate ; @Container public static PostgreSQLContainer<?> postgresDB = new PostgreSQLContainer<> ("postgres:13.2") .withDatabaseName("eis") .withUsername("postgres") .withPassword("postgres") .withInitScript("ddl.sql"); @DynamicPropertySource public static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url",postgresDB::getJdbcUrl); registry.add("spring.datasource.username", postgresDB::getUsername); registry.add("spring.datasource.password", postgresDB::getPassword); } }
Cùng phân tích đoạn code trên:
@SpringBootTest - annotation @SpringBootTest nói với spring boot tìm kiếm main configuration class và sử dụng class đó để khởi động spring application context
@Testcontainers - tìm tất cả các trường có @Container và gọi các phương thức vòng đời (prePost, preDestroy, ...) của chúng trong quá trình thực thi test
@DirtiesContext - đảm bảo mỗi subclass test sẽ có ApplicationContext riêng với các thuộc tính động chính xác
TestRestTemplate - giả lập cách tính huống call HTTP, một phiên bản mock của RestTemplate
@Container - sử dụng để đánh dấu container nên được quản lý bởi Testcontainer extension
Nếu đang sử dụng junit 5 và springboot version < 2.2.6 bạn có thể cấu hình như sau:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@ContextConfiguration(initializers = BaseIT2.TestEnvInitializer.class)
@DirtiesContext
public class BaseIT2 { @Autowired protected TestRestTemplate testRestTemplate ; @Container private static PostgreSQLContainer<?> postgresDB = new PostgreSQLContainer<> ("postgres:13.2") .withDatabaseName("testdb") .withUsername("postgres") .withPassword("postgres") .withInitScript("ddl.sql"); static class TestEnvInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { @Override public void initialize(ConfigurableApplicationContext applicationContext) { TestPropertyValues values = TestPropertyValues.of( "spring.datasource.url=" + postgresDB.getJdbcUrl(), "spring.datasource.password=" + postgresDB.getPassword(), "spring.datasource.username=" + postgresDB.getUsername() ); values.applyTo(applicationContext); } } }
3.3. Manual container starting
Trong cách này chúng ta sẽ sử dụng singleton pattern để share container sang các test cases. Phương pháp này phù hợp để chạy một lượng lớn test cases nhưng cần cẩn thận để clear data giữa các testcases
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@DirtiesContext
public class BaseIT { @Autowired protected TestRestTemplate testRestTemplate ; public static PostgreSQLContainer<?> postgresDB; static { postgresDB = new PostgreSQLContainer<>("postgres:13.2") .withDatabaseName("eis"); postgresDB.start(); } @DynamicPropertySource public static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url",postgresDB::getJdbcUrl); registry.add("spring.datasource.username", postgresDB::getUsername); registry.add("spring.datasource.password", postgresDB::getPassword); } }
Với cách này chúng ta sử dụng static block để define start container nên chúng sẽ trở nên khả dụng khi chương trình bắt đầu.
Hết o.O !!!