在我们的项目中,单元测试是保证我们代码质量非常重要的一环,但是我们的业务代码不可避免的需要依赖外部的系统或服务如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 {
@LocalServerPort
private Integer port;
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:14"
);
@BeforeAll
static void beforeAll() {
postgres.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
}
@DynamicPropertySource
static 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);
}
@Autowired
CustomerRepository customerRepository;
@BeforeEach
void setUp() {
RestAssured.baseURI = "http://localhost:" + port;
customerRepository.deleteAll();
}
@Test
void shouldGetAllCustomers() {
List<Customer> customers = List.of(
new Customer(null, "John", "[email protected]"),
new Customer(null, "Dennis", "[email protected]")
);
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
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:14"
);
@Autowired
CustomerRepository customerRepository;
@Test
void shouldGetAllCustomers() {
List<Customer> customers = List.of(
new Customer(null, "John", "[email protected]"),
new Customer(null, "Dennis", "[email protected]")
);
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 {
@Autowired
CustomerRepository customerRepository;
@Test
void shouldGetAllCustomers() {
List<Customer> customers = List.of(
new Customer(null, "John", "[email protected]"),
new Customer(null, "Dennis", "[email protected]")
);
customerRepository.saveAll(customers);
assertTrue(customerRepository.findByName("John").isPresent());
assertEquals(2,customerRepository.findAll().size());
}
@TestConfiguration(proxyBeanMethods = false)
public static class MyPostgreConfiguration {
@Bean
@ServiceConnection
public 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 {
@Container
static GenericContainer<?> container = new GenericContainer<>(
"demo/demo:latest"
).withExposedPorts(8080);
@DynamicPropertySource
static 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);
}
@Autowired
ExternalCustomClient customClient;
@Test
void 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