软件集成测试之利器-Testcontainers

429 阅读3分钟

描述

一直以来集成测试所产生的脏数据都是一个令人头疼的问题。而Testcontainers正好可以被用来解决这一问题。Testcontainers的原理时在运行测试时使用Docker容器中的数据库,测试结束后容器就会被被清除,这使得每次运行测试时数据库都处在一个干净的状态。

Testcontainers支持MysqlPostgresMongoDB等常见数据库,并且可以很好的和JUnit 4Jupiter/JUnit 5Spock等测试框架协作。本文以Spring data jpa + Mysql为例。编写订单订单项场景下的增删改查集成测试。

Testcontainers使用示例

新建项目

新建一个web项目并选择相关依赖。这里使用Spring Initializr初始化项目。

image.png

添加Testcontainers依赖

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.10.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <version>1.10.7</version>
    <scope>test</scope>
</dependency>

创建实体

订单订单项的场景为例,创建一个订单实体和一个订单项实体。使用@OneToMany注解描述订单订单项的关系为一对多

@Data
@Entity
@NoArgsConstructor
public class OrderInfo {

    public OrderInfo(Set<OrderItem> orderItems){
        orderItems.forEach(this::addOrderItem);
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 总价
     */
    private BigDecimal totalPrice = BigDecimal.ZERO;

    /**
     * 所有订单项
     */
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private Set<OrderItem> orderItems = new HashSet<>();

    /**
     * 新增订单项
     */
    public OrderInfo addOrderItem(OrderItem orderItem){

        this.orderItems.add(orderItem);

        BigDecimal orderItemTotalPrice = orderItem.getUnitPrice().multiply(BigDecimal.valueOf(orderItem.getQuantity()));
        this.totalPrice = this.totalPrice.add(orderItemTotalPrice);

        return this;
    }

}

@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 商品名
     */
    private String tradeName;

    /**
     * 数量
     */
    private Integer quantity;

    /**
     * 单价
     */
    private BigDecimal unitPrice;
}

创建实体的Repository

public interface OrderRepo extends PagingAndSortingRepository<OrderInfo, Long> {

}

编写service

编写简单的增删改查功能

public interface OrderService {

    OrderInfo getOrderById(Long id);

    OrderInfo addOrder(OrderInfo orderEntity);

    void delOrder(Long id);

    OrderInfo addOrderItem(Long orderId, OrderItem orderItem);
}
@Service
@AllArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderRepo orderRepo;

    @Override
    public OrderInfo getOrderById(Long id) {

        return orderRepo.findById(id).orElse(null);
    }

    @Override
    public OrderInfo addOrder(OrderInfo orderEntity) {

        return orderRepo.save(orderEntity);
    }

    @Override
    public void delOrder(Long id) {

        orderRepo.deleteById(id);
    }

    @Override
    public OrderInfo addOrderItem(Long orderId, OrderItem orderItem) {

        OrderInfo orderInfo = orderRepo.findById(orderId).orElseThrow(() -> new RuntimeException("订单不存在"));

        orderInfo.addOrderItem(orderItem);
        return orderRepo.save(orderInfo);
    }
}

编写测试代码

编写配置文件

test目录下的resources目录下创建application.yml文件,并写入一下配置。

spring:
  jpa:
    properties:
      hibernate:
        dialect: com.example.integrationtestdemo.config.MysqlConfig  # 创建表时使用自定义的编码和存储引擎
    generate-ddl: true     # 构建ddl语句
    show-sql: true         # 打印sql
    hibernate:
      ddl-auto: create     # 自动创建数据库表

配置jpa生成数据库表的编码存储引擎

public class MysqlConfig extends MySQL57Dialect {
    @Override
    public String getTableTypeString() {
        // 配置使用`InnoDB`存储引擎和utf8mb4编码
        return " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
    }
}

创建一个测试基类

创建测试基类,在这个基类中启动mysql容器供测试使用并将相关数据源配置注入spring环境中。之后在进行测试时只需要继承这个基类即可

@SpringBootTest
@ContextConfiguration(initializers = IntegrationTestBase.DockerMySQLDataSourceInitializer.class)  // 注入数据源
public class IntegrationTestBase {

    public static final MySQLContainer<?> mysql;

    static {
        // 配置使用mysql:5.7.30镜像,并创建使用jdbc数据库
        mysql = new MySQLContainer<>("mysql:5.7.30").withDatabaseName("jdbc");
        mysql.start();
    }

    public static class DockerMySQLDataSourceInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {

            // 注入数据源
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.datasource.url=" + mysql.getJdbcUrl() + "?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai",
                    "spring.datasource.username=" + mysql.getUsername(),
                    "spring.datasource.password=" + mysql.getPassword(),
                    "spring.datasource.driver-class-name=" + mysql.getDriverClassName()
                                                                     );
        }

    }
    
}

继承这个基类实现集成测试

通过集成上面编写的基类即可进行集成测试。为了使用测试的方法在一个环境中运行可以在测试类上添加注解@TestInstance(TestInstance.Lifecycle.PER_CLASS)

// 使测试类中所有方法在一个测试环境中运行
@TestInstance(TestInstance.Lifecycle.PER_CLASS)  
class OrderInfoServiceTest extends IntegrationTestBase {

    @Autowired
    private OrderService orderService;

    // 记录保存的对象,以便查询时进行比较
    private OrderInfo orderInfo;

    @Test
    @Order(1)
    void addOrder() {

        OrderItem bookOrderItem = OrderItem.builder()
                .tradeName("书籍")
                .quantity(3)
                .unitPrice(BigDecimal.valueOf(10))
                .build();

        OrderItem notebookOrderItem = OrderItem.builder()
                .tradeName("笔记本")
                .quantity(5)
                .unitPrice(BigDecimal.valueOf(20))
                .build();

        Set<OrderItem> bookOrderItems = Set.of(bookOrderItem, notebookOrderItem);
        OrderInfo orderInfo = new OrderInfo(bookOrderItems);

        this.orderInfo = orderService.addOrder(orderInfo);

        assert orderInfo.getId() != null : "新增失败";
    }

    @Test
    @Order(2)
    void getOrderById() {

        OrderInfo orderInfo = orderService.getOrderById(this.orderInfo.getId());

        assert orderInfo != null : "通过id未找到数据";
        assert this.orderInfo.getTotalPrice().compareTo(orderInfo.getTotalPrice()) == 0 : "数据不一致";
        assert this.orderInfo.getOrderItems().size() == orderInfo.getOrderItems().size() : "数据不一致";
    }

    @Test
    @Order(3)
    void addOrderItem() {

        OrderItem pictureAlbumOrderItem = OrderItem.builder()
                .tradeName("画册")
                .quantity(3)
                .unitPrice(BigDecimal.valueOf(50))
                .build();

        this.orderInfo = orderService.addOrderItem(this.orderInfo.getId(), pictureAlbumOrderItem);
        OrderInfo orderInfo = orderService.getOrderById(this.orderInfo.getId());


        assert this.orderInfo.getTotalPrice().compareTo(orderInfo.getTotalPrice()) == 0 : "数据不一致";
        assert this.orderInfo.getOrderItems().size() == orderInfo.getOrderItems().size() : "数据不一致";
    }

    @Test
    @Order(4)
    void delOrder() {

        orderService.delOrder(this.orderInfo.getId());

        OrderInfo orderInfo = orderService.getOrderById(this.orderInfo.getId());

        assert orderInfo == null : "删除订单失败";
    }
    
}

项目结构如下: image.png

测试结果

运行测试时会在本地Dokcer环境中启动Docker容器 image.png

在运行日志中也可以看到启动的容器信息 image.png

运行结果如下: image.png

image.png

image.png

image.png

项目github地址