CI+JUnit5并发单测机制创新实践 | 京东物流技术团队

一. 现状·问题

针对现如今高并发场景的业务系统,"并发问题" 终归是必不可少的一类(占比接近10%),每次出现问题和事故后,需要耗费大量人力成本排查分析并修复。那如果能在事前尽可能避免岂不是很香?

二. 分析原因

  • 当前并发测试多数依赖测试人员进行脚本测试,同时还依赖了研发和产品识别出并发操作的场景用例。
  • 对于并发测试,大概两条路子:
  1. 所有修改同样数据的命令式接口都测一遍?【耗费巨大测试成本】
  2. 保证黄金流程的接口,研发从头扒代码。【可能会遗漏,耗费一定研发成本】

🤔自我反思

  • 作为研发,是不是在刚开发接口时候,识别到并发场景随着单元测试阶段同时进行并发测试,这样的成本是最小的,收益是最高效的!

三. 采取措施

并发测试前置

采用CI持续集成机制,依靠行云流水线 ,底层利用junit5 单元测试框架并发parallel 引擎,嵌入同步数据库的自定义unit test脚本 ,将每个并发case维护成单元测试,数据自我闭环,可重复执行

将核心的并发场景进行及时的运行验证,最早洞察,最早验证,最小成本,最大保障!

四. 实践步骤

前提:配置junit-platform.properties

ini 复制代码
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=20

单接口并发-@RepeatedTest

  • ManualCheckAppConcurrentTest 出库复核并发测试「单接口并发」-> 手动复核 10个线程

👉 核心代码块

ruby 复制代码
public class ManualCheckAppConcurrentTest extends ConcurrentTest {


    @Resource
    ManualCheckAppService manualCheckAppService;
  
    //记录执行成功的线程数
    static int successThreadCount = 0;


    ///////////////////////////////////////////////////////////////////////////
    // 单接口并发
    ///////////////////////////////////////////////////////////////////////////
    @DisplayName("(单接口并发)并发测试【手动确认复核】")
    @Description("(10个线程)场景:复核1件,一共5件,应该有5个线程成功,5个线程失败:没有查询到容器明细记录" +
            "使用友好式分布式锁防止并发,并发后等待重试,保证顺序执行无异常!")
    @Execution(CONCURRENT)
    @RepeatedTest(value = 10, name = "{displayName}:{totalRepetitions}-{currentRepetition}")
    public void testConfirmChecked(TestInfo testInfo) {
    
          manualCheckAppService.confirmChecked(mockConfirmCheckedDto());
          successThreadCount++;
    }


    /**
     * 断言最终结果:数据无问题,线程执行无问题
     */
    @AfterAll
    public static void assertResult() {
        //线程执行成功数期望:一共5件,每个线程复核1件,共有5个线程成功
        Assertions.assertEquals(5, successThreadCount);
        //数据成功期望:没有待复核的容器明细了,因为都复核成功了,一共5件
        ConfirmCheckedDto confirmCheckedDto = mockConfirmCheckedDto();
        List<ContainerDetailPo> containerDetailPos = SpringUtil.getBean(ContainerDetailDao.class).selectUncheckDetailsBySoAndSku(
                confirmCheckedDto.getTaskNo(), confirmCheckedDto.getShipmentOrderNo(), confirmCheckedDto.getSku(), confirmCheckedDto.getWarehouseNo());
        Assertions.assertTrue(CollectionUtils.isEmpty(containerDetailPos));
    }


    @Test
    @Sql({"/concurrent/manualCheck.sql"})
    @Override
    void prepareData()

多场景并发-@Execution(CONCURRENT)

  • CheckAppConcurrentTest 出库复核并发测试「多场景并发」-> 手动复核|自动复核

👉 核心代码块

ruby 复制代码
public class CheckAppConcurrentTest extends ConcurrentTest {


    @Resource
    ManualCheckAppService manualCheckAppService;
    @Resource
    AutoCheckAppService autoCheckAppService;


    ///////////////////////////////////////////////////////////////////////////
    // 多场景并发
    ///////////////////////////////////////////////////////////////////////////
    @DisplayName("(多场景并发)并发测试【自动确认复核】")
    @Description("与手动复核发生并发场景,期望可能存在业务异常(自定义锁冲突发生的消息)")
    @Execution(CONCURRENT)
    @Test
    public void testAutoCheckBySo() {
        autoCheckAppService.autoCheckBySo(Lists.newArrayList("SO-6_6_601-1492066800186167296"), mockAutoCheckBySoDto());
    }


    @DisplayName("(多场景并发)并发测试【手动确认复核】")
    @Description("与自动复核发生并发场景,期望可能存在业务异常(自定义锁冲突发生的消息)")
    @Execution(CONCURRENT)
    @Test
    public void testConfirmChecked() {
        manualCheckAppService.confirmChecked(mockConfirmCheckedDto());
    }
    /**
     * 断言最终结果:数据无问题
     */
    @AfterAll
    public static void assertResult() {
        //数据成功期望:没有待复核的容器明细了,无论是手动复核还是自动复核,都会全部复核完
        ConfirmCheckedDto confirmCheckedDto = mockConfirmCheckedDto();
        List<ContainerDetailPo> containerDetailPos = SpringUtil.getBean(ContainerDetailDao.class).selectUncheckDetailsBySoAndSku(
                confirmCheckedDto.getTaskNo(), confirmCheckedDto.getShipmentOrderNo(), confirmCheckedDto.getSku(), confirmCheckedDto.getWarehouseNo());
        Assertions.assertTrue(CollectionUtils.isEmpty(containerDetailPos));
    }


    @Test
    @Sql({"/concurrent/manualCheck.sql"})
    @Override
    void prepareData() {}

并发单测基类-@Transactional

ConcurrentTest 建议抽出并发测试基类(主要目的:准备数据、设置路由、数据清除、独立执行)

@Tag("parallel")分组: 并发测试用例,有助于单独执行套件! ​

👉 核心代码块

less 复制代码
@SpringBootTest(classes = WebApplication.class)
@Tag("parallel")
public abstract class ConcurrentTest {
    /**
     * 并发测试场景的前提数据准备
     * { @Sql 数据脚本配置 }
     */
    @Transactional
    @Order(0)
    @Rollback(false)
    abstract void prepareData();
    /**
     * 设置当前线程数据源
     */
    @BeforeTransaction
    public void setThreadDataSource() {
        DataSourceContextHolder.clearDataSourceKey();
        //多数据源,分库分表
        DataSourceContextHolder.setDataSource("ds0");
    }


   /**
     * 清除数据
     */
    @Rollback(false)
    @AfterAll
    public static void clearData(){
        new DatabaseSyncTest().execute("wms_check","wms_check_test");
    }

数据准备-@Sql

如何准备数据?

=> 新建一个专门单元测试/并发测试的空数据库

准备测试场景的前置数据SQL脚本

👉 源脚本

sql 复制代码
DELETE FROM ck_task;
INSERT INTO ck_task (id, task_no, sku_qty, total_qty, platform_no, status, warehouse_no, create_user,
                                    update_user, create_time, update_time, ts, deleted, suggest_platform, uuid,
                                    parent_task_no, pick_differ_allow, operation_type, picking_flag, task_type,
                                    ext_info,
                                    subtask_qty, tenant_code, current_stream_no, confluence, batch_no, requirements)
VALUES (1492071049884340224, 'T6X6X60122021100000329', 1.0000, 5.0000, '', 0, '6_6_601', 'xiaoyan', 'xiaoyan',
        '2022-02-11 17:45:26', '2022-02-11 17:45:26', '2022-02-11 17:45:26', 0, '', 'zyr1228003', '', 0, 0, 0, 0, null,
        null, 'TC30020150', 0, 1, 'cj006001', '{"allowBatchCheck": true}');     

数据回滚-@ParameterizedTest

CI自动同步数据库表结构: 测试环境数据库->单测数据库

利好:(研发无需被动维护schema,自动与真实数据库结构同步)

只需要将下面单测copy到代码中,将fromDb和toDb参数修改成自己数据库即可!

👉 源代码

less 复制代码
    @DisplayName("单元测试MYSQL-DB结构同步")
    @SneakyThrows
    @ParameterizedTest
    @CsvSource("wms_check,wms_check_test")
    public void execute(String fromDb, String toDb) {
        ResultSet resultSet = null;
        Class.forName("com.mysql.jdbc.Driver");
        try (
                Connection connection = DriverManager.getConnection("***","user", "***");
                Statement statement = connection.createStatement()
        ) {
            String initDb = "DROP DATABASE IF EXISTS " + toDb + ";CREATE DATABASE " + toDb + ";";
            log.info(initDb);
            statement.executeUpdate(initDb);
            resultSet = statement.executeQuery("SHOW TABLES FROM " + fromDb + ";");
            List<String> tableNames = Lists.newArrayList();
            while (resultSet.next()) {
                tableNames.add(resultSet.getString("Tables_in_" + fromDb));
            }
            for (String tableName : tableNames) {
                String syncSql = "DROP TABLE IF EXISTS " + toDb + "." + tableName + ";" +
                        "CREATE TABLE " + toDb + "." + tableName + " LIKE " + fromDb + "." + tableName + ";";
                log.info(syncSql);
                statement.executeUpdate(syncSql);
            }
        } finally {
            if(resultSet != null){
                resultSet.close();
            }
        }
    }

配置CI-@行云流水线

建议在提测流水线增加,不要再日常dev流水线(集成测试相对耗时)

只执行并发单测用例-Dtest.mode 基于junit5 @Tag

junit.org/junit5/docs...

ini 复制代码
mvn test -Dtest.mode=parallel

配置IDEA-本地测试

------ 只运行并发测试用例

执行结果

单接口并发单测

多场景并发单测

五. 效能提升

5.1需求交付效率提升

5.1.1降低测试周期阶段时长

2022-02月实践后

因为「并发测试」前置到「研发单元测试」环节,所以「测试阶段」时长缩短 (2.5 天 -> 1 天)

2022-Q1

2022-Q2

2022-Q3

2022-Q4

「测试周期」阶段停留时长和占比,呈下降趋势!

5.1.2缩短需求交付全周期

2022-02月实践后

因为「测试周期」缩短,研发单元测试成本几乎不变,所以「需求交付全周期」随之缩短(55 天 -> 35 天)!

5.2人效提升

5.2.1提升验证全面性

「case by case」 ,通过单元测试「断言机制」,最细粒度全方位验证!

在【开发阶段】识别到接口存在并发问题,及时编写单元测试进行验证,针对分布式锁和乐观锁等常用防并发手段,对应不同的assert方式:

  • 数据库乐观锁:通过判断最终数据保证执行无问题
  • 分布式友好锁:不会报错,会等待,最终所有请求处理成功
  • 分布式冲突锁:直接报错,断言异常信息
  • ......

5.2.2降低测试人力成本

减少花大量时间专项测试N个接口并发测试成本,「最早发现,最早处理,最小成本」!

根据下图可见,从编码阶段、单元测试阶段、接口测试阶段、集成测试阶段、预发布阶段等软件生命周期中,越早发现问题,付出成本越小。

5.2.3提升需求吞吐量

2022-02月实践后

因为减少人力成本,所以会直接提升需求的吞吐量(200个 -> 225个)!

5.3过程质量提升

5.3.1降低问题的发生概率

「并发测试前置」 到研发单元测试环节,可减少缺陷数,降低问题发生概率!

5.3.2减少线上问题数

👉 今年线上问题-并发问题 类别为 0

5.3.2减少Bug数

👉过程质量中并发问题趋势逐步降低

作者:京东物流 周奕儒

来源:京东云开发者社区 自猿其说Tech

相关推荐
surfirst6 小时前
举例说明 .Net Core 单元测试中 xUnit 的 [Theory] 属性的用法
单元测试·.netcore·xunit
回眸&啤酒鸭18 小时前
【回眸】Tessy 单元测试软件使用指南(四)常见报错及解决方案与批量初始化的经验
单元测试·tessy
Iam傅红雪2 天前
mock数据,不使用springboot的单元测试
spring boot·后端·单元测试
月光code2 天前
SLF4J报错log4j又报错
单元测试·log4j
帅得不敢出门2 天前
安卓使用memtester进行内存压力测试
android·压力测试·测试·硬件测试
编程经验分享3 天前
Spring Boot 基于 Mockito 单元测试
spring boot·后端·单元测试
神即道 道法自然 如来3 天前
测试面试题:请你分别介绍一下单元测试、集成测试、系统测试、验收测试、回归测试
单元测试·集成测试
友恒3 天前
C#单元测试(一):用 NUnit 和 .NET Core 进行单元测试
单元测试·c#·.netcore
进击的横打4 天前
【车载开发系列】ParaSoft单元测试环境配置(四)
c语言·单元测试
wangyue44 天前
C# MSTest 进行单元测试
单元测试