在我們的項目中,單元測試是保證我們代碼質量非常重要的一環,但是我們的業務代碼不可避免的需要依賴外部的系統或服務如DB,redis,其他外部服務等。如何保證我們的測試代碼不受外部依賴的影響,能夠穩定的運行成為了一件比較讓人頭疼的事情。
mock
通過mockito等框架,可以模擬外部依賴各類組件的返回值,通過隔離的方式穩定我們的單元測試。但是這種方式也會帶來以下問題:
- 測試代碼冗長難懂,因為要在測試中模擬各類返回值,一行業務代碼往往需要很多測試代碼來支撐
- 無法真實的執行SQL腳本
- 僅僅適用于單元測試,對于端到端的測試無能為力
testcontainer
testcontainer,人如其名,可以在啟動測試時,創建我們所依賴的外部容器,在測試結束時自動銷毀,通過容器的技術來達成測試環境的隔離。
目前testcontainer僅支持docker,使用testcontainer需要提前安裝docker環境
簡單的例子
假設我們有一個springboot的web項目,在這個項目中使用postgresql作為數據庫
首先我們將testcontainer添加到pom.xml中
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><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><dependency><groupId>io.rest-assured</groupId><artifactId>rest-assured</artifactId><scope>test</scope></dependency></dependencies>
然后是我們的entity對象和Repository
@Entity
@Table(name = "customers")
public class Customer {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(nullable = false)private String name;@Column(nullable = false, unique = true)private String email;public Customer() {}public Customer(Long id, String name, String email) {this.id = id;this.name = name;this.email = email;}public Long getId() {return id;}public void setId(Long id) {this.id = id;}public String getName() {return name;}public void setName(String name) {this.name = name;}public String getEmail() {return email;}public void setEmail(String email) {this.email = email;}
}
public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
最后是我們的controller
@RestController
public class CustomerController {private final CustomerRepository repo;CustomerController(CustomerRepository repo) {this.repo = repo;}@GetMapping("/api/customers")List<Customer> getAll() {return repo.findAll();}
}
由于是demo,我們通過src/resources/schema.sql
來進行表的初始化,在正常項目中應該通過flyway等進行數據庫的管理
create table if not exists customers (id bigserial not null,name varchar not null,email varchar not null,primary key (id),UNIQUE (email)
);
并在application.properties
中添加如下配置
spring.sql.init.mode=always
最后是重頭戲,如果通過testcontainer來完成我們的端到端測試
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasSize;import com.example.testcontainer.domain.Customer;
import com.example.testcontainer.repo.CustomerRepository;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CustomerControllerTest {@LocalServerPortprivate Integer port;static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");@BeforeAllstatic void beforeAll() {postgres.start();}@AfterAllstatic void afterAll() {postgres.stop();}@DynamicPropertySourcestatic void configureProperties(DynamicPropertyRegistry registry) {registry.add("spring.datasource.url", postgres::getJdbcUrl);registry.add("spring.datasource.username", postgres::getUsername);registry.add("spring.datasource.password", postgres::getPassword);}@AutowiredCustomerRepository customerRepository;@BeforeEachvoid setUp() {RestAssured.baseURI = "http://localhost:" + port;customerRepository.deleteAll();}@Testvoid shouldGetAllCustomers() {List<Customer> customers = List.of(new Customer(null, "John", "john@mail.com"),new Customer(null, "Dennis", "dennis@mail.com"));customerRepository.saveAll(customers);given().contentType(ContentType.JSON).when().get("/api/customers").then().statusCode(200).body(".", hasSize(2));}
}
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
“postgres:14”
);`
這里創建了一個postgres的容器,然后通過 @DynamicPropertySource
為spring.datasource
的各項參數賦值,剩下的就由Spring的auto-configure完成各類bean的自動裝配,JPA的裝配和注入。
除了通過 @DynamicPropertySource
外,spring-boot-testcontainers
提供了一些方法可以更加簡化這個流程
首先添加依賴
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
@Testcontainers
@SpringBootTest
public class MyIntegrationServiceConnectionTests {@Container@ServiceConnectionstatic PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");@AutowiredCustomerRepository customerRepository;@Testvoid shouldGetAllCustomers() {List<Customer> customers = List.of(new Customer(null, "John", "john@mail.com"),new Customer(null, "Dennis", "dennis@mail.com"));customerRepository.saveAll(customers);assertTrue(customerRepository.findByName("John").isPresent());assertEquals(2,customerRepository.findAll().size());}
}
@Testcontainers
注解會幫助我們在測試開始前啟動容器,在測試結束后停止容器,因此我們可以省略@BeforeAll和@AfterAll
@ServiceConnection
取代了 @DynamicPropertySource
中代碼的功能,幫助我們完成bean的創建
除此之外,我們也可以通過@bean的方式來創建容器
@Testcontainers
@SpringBootTest
public class MyIntegrationBeanConfiguratonTests {@AutowiredCustomerRepository customerRepository;@Testvoid shouldGetAllCustomers() {List<Customer> customers = List.of(new Customer(null, "John", "john@mail.com"),new Customer(null, "Dennis", "dennis@mail.com"));customerRepository.saveAll(customers);assertTrue(customerRepository.findByName("John").isPresent());assertEquals(2,customerRepository.findAll().size());}@TestConfiguration(proxyBeanMethods = false)public static class MyPostgreConfiguration {@Bean@ServiceConnectionpublic PostgreSQLContainer<?> postgreSQLContainer() {return new PostgreSQLContainer<>("postgres:14");}}
}
連接web服務
PostgreSQLContainer是一個專門用于連接PostgreSQL的類,除此之外testcontainer還提供了redis、mysql、es、kafka等常用中間件的容器類。如果我們想要去連接一個內部的web服務,那該怎么做?
首先,我們要確保該服務已經容器化,可以直接通過docker來啟動,其次,testcontainer提供了一個公共的GenericContainer
來處理這類場景,
假設我們有一個服務鏡像demo/demo:latest
,該服務暴露了一個8080端口,我們通過feignclient來訪問
@FeignClient(name = "customFeign",url = "${remote.custom.url}")
public interface ExternalCustomClient {@GetMapping("custom/{id}")public CustomInfo getCustom(@PathVariable("id") String id);
}
@Testcontainers
@SpringBootTest
public class ExternalCustomClientTest {@Containerstatic GenericContainer<?> container = new GenericContainer<>("demo/demo:latest").withExposedPorts(8080);@DynamicPropertySourcestatic void configureProperties(DynamicPropertyRegistry registry) {Integer firstMappedPort = container.getMappedPort(8080);String ipAddress = container.getHost();System.out.println(ipAddress);System.out.println(firstMappedPort);registry.add("remote.custom.url",() -> "http://"+ipAddress+":"+firstMappedPort);}@AutowiredExternalCustomClient customClient;@Testvoid shouldGetAllCustomers() {String id ="111";CustomInfo customInfo=customClient.getCustom(id);Assertions.assertEquals(id, customInfo.getCustomNo());}}
由于使用了GenericContainer
,Springboot不知道該如何去連接容器,因此不能使用@ServiceConnection注解,還是回到@DynamicPropertySource的方式。
@Container
static GenericContainer<?> container = new GenericContainer<>(
“zhangxiaotian/caizi:latest”
).withExposedPorts(8080);
在容器內部監聽了一個8080端口,這個需要和外部服務的端口一致,對容器外部暴露的端口,我們可以通過
Integer firstMappedPort = container.getMappedPort(8080);
String ipAddress = container.getHost();
來獲取完整的ip和端口
完整的代碼示例可以通過以下倉庫獲取
https://gitee.com/xiiiao/testcontainer-demo