前言

以下所有的样例代码都是基于 junit5 + jmockit(推荐的单测基础框架选型),大部分是基于项目的真实代码片段

POM 依赖说明

// 主要 pom 说明
<!-- 自己封装的工具类-->
<dependency>
    <groupId>cn.kubeclub</groupId>
    <artifactId>fastjunit</artifactId>
    <version>1.0.1-release</version>
</dependency>

<dependency>
    <groupId>org.jmockit</groupId>
    <artifactId>jmockit</artifactId>
    <version>1.36</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.3.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine </artifactId>
    <version>5.3.0</version>
    <scope>test</scope>
</dependency>

&lt;!-- redis mock--&gt;
<dependency>
    <groupId>com.github.fppt</groupId>
    <artifactId>jedis-mock</artifactId>
    <version>0.1.22</version>
    <scope>test</scope>
</dependency>

&lt;!-- mq mock--&gt;
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka-test</artifactId>
    <scope>test</scope>
</dependency>

场景内容

  1. 依赖注入
  2. 跨任意类 mock
  3. 静态/私有-方法/类/接口 mock
  4. 函数式断言
  5. 方法调用情况断言
  6. 造数据
  7. CRUD 贫血操作业务的单测
  8. 核心复杂业务 service 单测
  9. 数据库内存替代方案-H2
  10. REDIS 测试基类
  11. 文件上传和下载
  12. 并行测试
  13. 参数化测试
  14. Controller session 单测
  15. ES 测试基类
  16. MQ 测试基类

依赖注入

@Mocked

  • 作用范围
    @Mocked 可修饰类/接口;影响类的所有实例。
  • 功能
    mocked对象方法(包含静态方法)返回默认值:即如果返回类型为原始类型(short,int,float,double,long)就返回0,如果返回类型为String就返回null,如果返回类型是其它引用类型,则返回这个引用类型的Mocked对象
  • 使用场景
    比如在分布式系统中,我们的测试程序依赖某个接口的实例是在远程服务器端时,我们在本地构建是非常困难的,此时就交给@Mocked,就太轻松啦

@Tested&@Injectable

  • 作用范围
    @Tested表示被测试对象;只影响类的一个实例
  • 功能
    若@Tested的构造函数有参数,则JMockit通过在测试属性&测试参数中查找@Injectable修饰的Mocked对象注入@Tested对象的构造函数来实例化,不然,则用无参构造函数来实例化。除了构造函数的注入,JMockit还会通过属性查找的方式,把@Injectable对象注入到@Tested对象中。 注入的匹配规则:先类型,再名称(构造函数参数名,类的属性名)。若找到多个可以注入的@Injectable,则选择最优先定义的@Injectable对象
  • 功能
    当我们需要手工管理被测试类的依赖时,就需要用到@Tested & @Injectable

@Capturing

  • 作用范围
    子类/实现类
  • 功能
    @Capturing主要用于子类/实现类的Mock
  • 功能
    只知道父类或接口时,但我们需要控制它所有子类的行为时,子类可能有多个实现(可能有人工写的,也可能是AOP代理自动生成时)。就用@Capturing

Expectations

  • 作用范围
    通过引用外部类的Mock对象(@Injectabe,@Mocked,@Capturing)来录制;通过构建函数注入类/对象来录制
  • 功能
    录制类/对象的调用,返回值是什么
  • 功能
    用来对mock对象的录制结果做限制

MockUp & @Mock

  • 作用范围
    对某个类进行局部mock
  • 功能
    进行局部方法模拟,不能mock接口 只会影响到部分方法,其余方法正常访问
  • 功能
    MockUp & @Mock比较适合于一个项目中,用于对一些通用类的Mock,以减少大量重复的new Exceptations{{}}代码

Verifications

  • 作用范围
    对某个方法实现验证
  • 功能
    验证Mock对象(即@Moked/@Injectable@Capturing修饰的或传入Expectation构造函数的对象)有没有调用过某方法,调用了多少次
  • 功能
    测试程序关心类的某个方法有没有调用,调用多少次,你可以使用new Verifications() {{}}验证代码块
 /**
     * 被测试对象,一般结合Injectable进行参数注入
     */
    @Tested
    private TaskIssueRelServiceImpl taskIssueRelService;

    /**
     * 同mocked类似,但是区别在于它只影响当前实例
     */
    @Injectable
    private TestIssueService testIssueService;

    @Injectable
    private TaskIssueRelDaoImpl taskIssueRelDao;

    @Test
    void issueNamesByTaskId() {
        List<TaskIssueRelBO> taskIssueRels = Lists.newLinkedList();
        /**
         * 用来实现mock对象的假设结果
         */
        new Expectations() {
            {
                taskIssueRelDao.listByParams((Map) any);
                result = taskIssueRels;
            }
        };
        Assertions.assertTrue(taskIssueRelService.issueNamesByTaskId("111") == null);
        /**
         * 验证方法被调用次数,一般不会使用,通常都用Assert来判断结果,
         * 无返回结果的方法可以确认调用情况
         */
        new Verifications() {
            {
                taskIssueRelDao.listByParams((Map) any);
                times = 1;
            }
        };
        TaskIssueRelBO taskIssueRelBO = new TaskIssueRelBO();
        taskIssueRelBO.setIssueNo("1");
        taskIssueRelBO.setTopic("test");
        taskIssueRels.add(taskIssueRelBO);
        Assertions.assertTrue(taskIssueRelService.issueNamesByTaskId("1").equals("【1】test"));
    }

    /**
     * mocked的方式所有方法都被mock掉了,包含静态方法
     * jmockit会自己实例化,不用像tested结合injected来完成初始化
     * mocked对象为接口或抽象类时会返回默认值
     * 一般用来mock其他服务的对象
     *
     * @param taskIssueRelService
     */
    @Test
    void testMock(@Mocked TaskIssueRelService taskIssueRelService, @Mocked Locale locale) {
        Assertions.assertTrue(taskIssueRelService.getMaxNumber() == 0L);
        Assertions.assertTrue(taskIssueRelService.getIssueNosByTaskId("20210410112332838019002108000001").isEmpty());
        Assertions.assertTrue(locale.getDefault() == null);
        Assertions.assertTrue(locale.getCountry() == null);
        Locale locale1 = new Locale("zh", "cn");
        Assertions.assertTrue(locale1.getCountry() == null);
    }

    /**
     * 进行局部方法模拟,不能mock接口
     * 只会影响到部分方法,其余方法正常访问
     */
    @Test
    void testMockUp() {
        List<String> result = Lists.newArrayList("1111");
        Assertions.assertTrue(taskIssueRelService.getMaxNumber().equals(0L));
        new MockUp<TaskIssueRelServiceImpl>(TaskIssueRelServiceImpl.class) {
            @Mock
            List<String> getIssueNosByTaskId(String taskId) {
                return result;
            }
            @Mock
            Long getMaxNumber() {
                return 1L;
            }
        };
        Assertions.assertTrue(taskIssueRelService.getIssueNosByTaskId("test").equals(result));
        Assertions.assertFalse(taskIssueRelService.getMaxNumber().equals(0L));

    }

class TeamDelayCountHandlerTest {

    @Tested
    private FlowDelayCountHandle flowDelayCountHandle;

    @Injectable
    private TaskDaoImpl taskDao;

    @Injectable
    private TeamDelayCountDaoImpl teamDelayCountDao;

    /**
     * 不仅能实现mock,还会将其所关联的子类的结果都影响到
     * 一般用于我们只知道父类或接口时,又想控制所有子类的行为时可以使用
     *
     * @param delayCountHandle
     */
    @Test
    void testCapturing(@Capturing ITeamDelayCountHandle delayCountHandle) {
        new Expectations() {
            {
                delayCountHandle.getDelayTypeEnum();
                result = DelayTypeEnum.JOB_DELAY;
            }
        };
        Assertions.assertFalse(flowDelayCountHandle.getDelayTypeEnum().equals(DelayTypeEnum.FLOW_FINISH_DELAY));
        Assertions.assertTrue(flowDelayCountHandle.getDelayTypeEnum().equals(DelayTypeEnum.JOB_DELAY));
    }

    @Test
    void testNoCapturing() {
        Assertions.assertTrue(flowDelayCountHandle.getDelayTypeEnum().equals(DelayTypeEnum.FLOW_FINISH_DELAY));
    }


}

跨任意类 mock

controller调用service,service 调用 mapper,以 controller mock mapper 为例,主要用到反射的工具类 ReflectionTestUtils。


@GetMapping(value = "/sdkVersionCheck/{ciPipelineId}/{gitPipelineId}/{serviceName}/{env}")
public AjaxResult sdkVersionCheck(@PathVariable("ciPipelineId") Long ciPipelineId,
                                  @PathVariable("gitPipelineId") Long gitPipelineId,
                                  @PathVariable("serviceName") String serviceName,
                                  @PathVariable("env") String env) {
    // sdk版本校验
    SdkDependencyCheckDTO sdkDependencyCheckDTO = new SdkDependencyCheckDTO(serviceName, env);
    SdkPipelineCheckDTO sdkPipelineCheckDTO = sdkDependencyCheckService.checkPipelineSdk(sdkDependencyCheckDTO);
    // 插入步骤记录
    Long ciRecordId = ciPipelineRecordService.getCiRecordIdByCiIdAndGitPipelineId(ciPipelineId, gitPipelineId);
    ciPipelineRecordThresholdService.insertSdkCheckResult(ciRecordId, sdkPipelineCheckDTO);
    boolean checkResult = sdkPipelineCheckDTO.getCheckResult();
    return AjaxResult.success(checkResult ? "SDK 版本校验通过" : "SDK 检验不通过", sdkPipelineCheckDTO);
}

@Service
public class CiPipelineRecordService{
    @Override
    public Long getCiRecordIdByCiIdAndGitPipelineId(Long ciPipelineId, Long gitPipelineId) {
        return ciPipelineRecordMapper.getCiRecordIdByCiIdAndGitPipelineId(ciPipelineId, gitPipelineId);
    }
}
/**
 * @author yongzhe.dong
 * @date 2021/9/14
 */
class CallbackCiPipelineControllerTest {

    @Tested
    private CallbackCiPipelineController callbackCiPipelineController;
    @Injectable
    private ICiPipelineRecordThresholdService ciPipelineRecordThresholdService;
    @Injectable
    private ISdkDependencyCheckService sdkDependencyCheckService;

    @DisplayName("测试 sdk 版本校验")
    @Test
    void testSdkVersionCheck(@Capturing CiPipelineRecordMapper ciPipelineRecordMapper) {
        Long ciPipelineId = 1L;
        Long gitPipelineId = 1L;
        String serviceName = "ucp";
        String env = "test";
        Long ciRecordId = 1L;
        SdkPipelineCheckDTO sdkPipelineCheckDTO = DataProvider.anyObject(SdkPipelineCheckDTO.class);
        sdkPipelineCheckDTO.setCheckResult(Boolean.TRUE);
        new Expectations() {
            {
                sdkDependencyCheckService.checkPipelineSdk(withNotNull());
                result = sdkPipelineCheckDTO;
    
                ciPipelineRecordThresholdService.insertSdkCheckResult(ciRecordId, sdkPipelineCheckDTO);
                times = 1;
    
                ciPipelineRecordMapper.getCiRecordIdByCiIdAndGitPipelineId(ciPipelineId, gitPipelineId);
                result = 1L;
            }
        };
        ICiPipelineRecordService ciPipelineRecordService = new CiPipelineRecordServiceImpl();
        // 利用反射工具注入
        ReflectionTestUtils.setField(ciPipelineRecordService, "ciPipelineRecordMapper", ciPipelineRecordMapper);
        ReflectionTestUtils.setField(callbackCiPipelineController, "ciPipelineRecordService", ciPipelineRecordService);
        AjaxResult ajaxResult = callbackCiPipelineController.sdkVersionCheck(ciPipelineId, gitPipelineId, serviceName, env);
        Assertions.assertThat(ajaxResult)
                .hasFieldOrPropertyWithValue("code", 200)
                .hasFieldOrPropertyWithValue("msg", "SDK 版本校验通过");
      }
}

方法调用情况断言

@Test 
public void sayHello1() { 
    // 录制(Record)
     new Expectations() { 
         { 
             helloJMockit.sayHello(); 
             // 期待上述调用的返回是"hello,david",而不是返回实际返回值 
         result = "hello david"; 
         } 
     };
      // 重放(Replay) 
      String msg = helloJMockit.sayHello(); 
      Assert.assertTrue(msg.equals("hello david")); 
      // 验证(Verification) 
      new Verifications() { 
          { 
              helloJMockit.sayHello();
               // 验证helloJMockit.sayHello()这个方法调用了1次 
               times = 1; 
           } 
       }; 
  }
         //利用Mockito.atLeastOnce()判断方法被调用的次数
        Mockito.verify(orderService, Mockito.atLeastOnce()).getOrder(1L);
        Mockito.verify(orderService, Mockito.times(1)).getOrder(1L);
        Mockito.verify(orderService, Mockito.never()).toString();
        //也可以利用Matchers判断入参是否按照预期
        Mockito.verify(orderService).getOrder( Matchers.eq( 1L ) );
        
        //捕获Mock方法的入参,判断该入参是否符合预期
        ArgumentCaptor<Long> argument = ArgumentCaptor.forClass(Long.class);
        Mockito.verify(orderService).getOrder(argument.capture());
        Assert.assertEquals((Long)1L, argument.getValue());

静态/私有方法 mock

Expectations 可以mock静态方法、普通方法、final方法,不能mock私有方法、native方法
MockUp 可以mock静态方法、普通方法、final方法、私有方法、native方法

DemoService

public class DemoService {
    // 静态方法
    public static String getString() {
        return "static method";
    }

    public Integer getInt() {
        return randomInt();
    }

    // 私有方法
    private int randomInt() {
        Random random = new Random();
        return random.nextInt(100);
    }
}

DemoServiceTest

public class DemoTest {
    @Tested
    DemoService demoService;

    @Test
    void staticMethodTest() {
        Assertions.assertEquals("static method", DemoService.getString());
        // mock 静态方法
        new Expectations(DemoService.class) {
            {
                DemoService.getString(); result = "mocked";
            }
        };
        Assertions.assertEquals("mocked", DemoService.getString());
    }

    @Test
    void privateMethodTest() {
        new DemoServiceMockUp();
        Assertions.assertEquals(99, demoService.getInt().intValue());
    }

    // 继承 MockUp 类 mock 私有方法 
    class DemoServiceMockUp extends MockUp<DemoService> {
        @Mock
        int randomInt() {
            return 99;
        }
    }
}

函数式断言(List,Map)


   /**
     * List将对象转为JSONArray然后进行比较
     *
     * @param actual   实际数据
     * @param expected 期望数据
     */
public static void assertArray(Object actual, Object expected, String... key) {
//        对实际数据和期望数据进行统一的日期格式化处理
        String actualJsonStr = JSON.toJSONStringWithDateFormat(actual, 
"yyyy-MM-dd HH:mm:ss", SerializerFeature.PrettyFormat);
        String expectedJsonStr = JSON.toJSONStringWithDateFormat(expected, 
"yyyy-MM-dd HH:mm:ss", SerializerFeature.PrettyFormat);
//        将需要校验的数组类型的数据转为JSONArray
        JSONArray actualArray = JSON.parseArray(actualJsonStr);
        JSONArray expectedArray = JSON.parseArray(expectedJsonStr);
//        如果有传入字段名则使用该字段名,未传入,则取"data"
        String filedName = key.length &lt;= 0 ? StrUtil.C_BRACKET_START + "data" + StrUtil.C_BRACKET_END : StrUtil.C_BRACKET_START + key[NUM_0] + StrUtil.C_BRACKET_END;

//        如果实际结果的长度和期望结果的长度不一致,校验失败
        if (actualArray.size() != expectedArray.size()) {
            log.info("返回的" + filedName + "数据为:" + StrUtil.CRLF + actualJsonStr);
            log.info("断言的" + filedName + "数据为:" + StrUtil.CRLF + expectedJsonStr);
            throw new AssertionError("返回的" + filedName + "字段,数组长度与断言数组的长度不一致,请检查!!!!");
        }

//        如果实际结果中不包含期望结果中的所有数据,校验失败
        if (!actualArray.containsAll(expectedArray)) {
            log.info("返回的" + filedName + "数据为:" + StrUtil.CRLF + actualJsonStr);
            log.info("断言的" + filedName + "数据为:" + StrUtil.CRLF + expectedJsonStr);
            throw new AssertionError("返回数据中存在断言数据中不存在的元素,请检查!!!!");
        }

//        如果期望结果中不包含实际结果中的所有数据,校验失败
        if (!expectedArray.containsAll(actualArray)) {
            log.info("返回的" + filedName + "数据为:" + StrUtil.CRLF + actualJsonStr);
            log.info("断言的" + filedName + "数据为:" + StrUtil.CRLF + expectedJsonStr);
            throw new AssertionError("断言数据中存在返回数据中不存在的元素,请检查!!!!");
        }
    }

/**
 * Map将对象转为JSONObject然后进行比较
 *
 * @param actual   实际数据
 * @param expected 期望数据
 */
public static void assertMap(Object actual, Object expected) {
    //将传入的响应数据解析为JSONObject
    JSONObject actualObject = (JSONObject) JSON.toJSON(expected);
    //将期望数据转为JSONObject
    JSONObject expectedObject = (JSONObject) JSON.toJSON(expected);

    //对于期望的每个字段进行比较
    for (String key : expectedObject.keySet()) {
        //如果字段对应的value为JSONArray,调用列表比较方法
        if (expectedObject.get(key) instanceof JSONArray) {
            assertArray(actualObject.get(key), expectedObject.get(key), key);
            continue;
        }
        // 如果字段对应的value为JSONObject,调用对象比较方法
        if (expectedObject.get(key) instanceof JSONObject) {
            assertObject(actualObject.get(key), expectedObject.get(key));
            continue;
        }

        //进行字段的比较
        Assert.assertEquals(actualObject.get(key), expectedObject.get(key), "字段[" + key + "]比较不一致,请检查!!!");
    }
}

接口 mock

@Injectable 注入一个接口实例,mock只对该实例有效
@Capturing 捕获接口所有实例,mock对接口的所有实现类都有效

DemoInterface

public interface DemoInterface {
    String getString();
}

DemoInterfaceTest

public class DemoTest2 {

    @Test
    void interfaceTest(@Injectable DemoInterface demoInterface) {
        // 该实现类没有被 mock
        DemoInterface myDemoInterfaceImpl = new DemoInterface() {
            @Override
            public String getString() {
                return "myDemoIntefraceImpl";
            }
        };
        Assertions.assertEquals("myDemoIntefraceImpl", myDemoInterfaceImpl.getString());
        Assertions.assertEquals(null, demoInterface.getString());

        new Expectations() {
            {
                demoInterface.getString(); result = "mock string";
            }
        };

        Assertions.assertEquals("mock string", demoInterface.getString());
        Assertions.assertEquals("myDemoIntefraceImpl", myDemoInterfaceImpl.getString());
    }

    @Test
    void interfaceTest2(@Capturing DemoInterface demoInterface // 捕获所有实现类) {
        DemoInterface myDemoInterfaceImpl = new DemoInterface() {
            @Override
            public String getString() {
                return "myDemoIntefraceImpl";
            }
        };
        DemoInterface myDemoIntefraceImpl2 = new DemoInterface() {
            @Override
            public String getString() {
                return "myDemoIntefraceImpl2";
            }
        };
        // 这两个实现类都被 mock 了
        Assertions.assertEquals(null, myDemoInterfaceImpl.getString());
        Assertions.assertEquals(null, myDemoIntefraceImpl2.getString());

        new Expectations() {
            {
                demoInterface.getString(); result = "mock string";
            }
        };

        Assertions.assertEquals("mock string", myDemoInterfaceImpl.getString());
        Assertions.assertEquals("mock string", myDemoIntefraceImpl2.getString());
    }
}

数据库内存替代方案-H2

主要针对 mybatis 和 H2 内存数据库实现对数据库的单元测试

单元测试中对 service 和 dao 层测试时,主要存在以下问题:

  • 需要启动完整的spring容器
  • 依赖中间件过多,测试配置文件需要编写与 DAO 层测试无意义的配置
  • 需要搭建各种环境(mysql,redis,kafka等)
  • 每个测试用例都对应一套SQL,并且会对本地的数据库造成影响

针对以上问题,我们在单元测试中解决以上问题:

  • 针对DAO层单元测试(service层会互相引用和循环依赖)
  • 不启动完整容器,不需要多余配置
  • 使用内存数据库,不需要搭建其他环境,保证测试隔离
  • 单个测试用例对应自己的SQL

h2 内存数据库的优势

  • 占用空间小
  • 即用即消
  • 读写速度很快
  • 支持嵌入模式使用数据库,在项目中只需导入依赖就可以使用
  • 不会污染各环境的正式数据库

具体步骤:

1. dao 测试工具类


/**
 * dao 测试工具类
 *
 * @author yongzhe.dong
 * @date 2020/09/27
 */
public class BaseDaoTestUtil {
    private static final Logger logger = LoggerFactory.getLogger(BaseDaoTestUtil.class);

    public final static String DRIVER = "org.h2.Driver";
    public final static String DB_URL = "jdbc:h2:mem:test;MODE=MySql;DB_CLOSE_DELAY=-1";
    public final static String USER = "root";
    public final static String PASSWORD = "123456";
    /**
     * 配置
     */
    private static Configuration configuration;

    private static final ThreadLocal<SqlSession> localSessions = new ThreadLocal&lt;>();


    static {
        try {
            // 定义 configuration
            configuration = new Configuration();
            // 不使用全局缓存
            configuration.setCacheEnabled(false);
            configuration.setLazyLoadingEnabled(false);
            configuration.setAggressiveLazyLoading(true);
            configuration.setDefaultStatementTimeout(20);
        } catch (Exception e) {
            logger.error("实例化 configuration 失败!", e);
        }
    }


    /**
     * 创建 sqlsession 会话
     *
     * @return
     */
    public static SqlSession getSqlSession() {
        SqlSession sqlSession = localSessions.get();
        if (sqlSession == null) {
            //设置数据库链接
            UnpooledDataSource dataSource = new UnpooledDataSource();
            dataSource.setDriver(DRIVER);
            dataSource.setUrl(DB_URL);
            dataSource.setUsername(USER);
            dataSource.setPassword(PASSWORD);
            //设置事务,使用 JDBC 的事务管理方式(测试设置事务不提交false)
            Transaction transaction = new JdbcTransaction(dataSource, TransactionIsolationLevel.READ_UNCOMMITTED, false);
            //设置执行
            Executor executor = configuration.newExecutor(transaction);
            //直接实例化一个默认的sqlSession
            //是做单元测试,那么没必要通过SqlSessionFactoryBuilder构造SqlSessionFactory,再来获取SqlSession
            sqlSession = new DefaultSqlSession(configuration, executor, false);
            localSessions.set(sqlSession);
        }
        return sqlSession;
    }

    /**
     * 获取 mapper 对象
     *
     * @param mapperXmlPath mapper xml 文件所在路径
     * @param mapperClass   mapper
     * @param <T>
     * @return
     */
    public static <T> T getMapper(String mapperXmlPath, Class<T> mapperClass) {
        try {
            //解析 mapper.xml 文件
            Resource mapperResource = new ClassPathResource(mapperXmlPath);
            XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperResource.getInputStream(), configuration, mapperResource.toString(), configuration.getSqlFragments());
            xmlMapperBuilder.parse();
            // 返回 mapper 实例
            return getSqlSession().getMapper(mapperClass);
        } catch (Exception e) {
            logger.error("实例化 mapper 失败!mapperXmlPath >>>>>{}", mapperXmlPath, e);
        }
        return null;
    }

    /**
     * 销毁会话
     */
    public static void closeSqlSession() {
        SqlSession sqlSession = localSessions.get();
        if (sqlSession != null) {
            sqlSession.close();
            localSessions.remove();
        }
        logger.info("#Close Session successfully");
    }
}

2. Resources 目录下放置初始化 SQL 脚本

输入图片说明

3. 使用工具类进行 dao 测试,以 TemplateCiDao 为例



/**
 * TemplateCi dao 测试
 * @author yongzhe.dong
 * @date 2021/9/28
 */
class TemplateCiDaoTest {

    private static SqlSession sqlSession;

    private static TemplateCiMapper templateCiMapper;


    @BeforeAll
    static void setUpAll() throws IOException {
        // 获取 mapper 实例
        templateCiMapper = BaseDaoTestUtil.getMapper("mapper/ci/TemplateCiMapper.xml", TemplateCiMapper.class);
        sqlSession = BaseDaoTestUtil.getSqlSession();
        Connection connection = sqlSession.getConnection();
        // 创建 ScriptRunner,读取 SQL 脚本并执行
        ScriptRunner runner = new ScriptRunner(connection);
        runner.setErrorLogWriter(null);
        runner.setLogWriter(null);
        // 执行初始化SQL脚本
        runner.runScript(Resources.getResourceAsReader("sql/TemplateCi.sql"));
    }

    @AfterEach
    void teardown() {
        sqlSession.commit();
    }

    @AfterAll
    static void teardownAll() {
        BaseDaoTestUtil.closeSqlSession();
    }

    @DisplayName("Test insert and query list")
    @Test
    void testSelectTemplateCiList() {
        Long templateId = 1L;
        TemplateCi templateCi = DataProvider.anyObject(TemplateCi.class);
        templateCi.setId(templateId);
        templateCi.setNeedSubmodules(1);
        templateCi.setNeedBranch(1);
        templateCi.setCmdbServiceName(1);
        // 插入
        templateCiMapper.insertTemplateCi(templateCi);
        // 查询
        List<TemplateCi> templateCis = templateCiMapper.selectTemplateCiList(new TemplateCi());
        // 断言
        Assertions.assertThat(templateCis)
                .isNotEmpty().containsExactly(templateCi);
    }

}

造数据

Fastjunit 包的工具方法

1. Bean对象

BeanObject beanObject = .anyObject(BeanObject.class);
System.out.println("anyObject:" + JsonUtils.writeJsonStr(beanObject));

2. 数组

BeanObject[] beanObjectArray = DataProvider.anyArray(BeanObject.class);
System.out.println("数组:" + JsonUtils.writeJsonStr(beanObjectArray));

3. 列表

BeanObject[] beanObjectArray = DataProvider.anyArray(BeanObject.class);
System.out.println("数组:" + JsonUtils.writeJsonStr(beanObjectArray));

CRUD 贫血操作业务的单测

业务中很多是普通的增删改查,针对这种场景,你一个个方法去测意义不大,为了覆盖率能达标且用例是有意义的,建议直接从 controler api 入口到 mapper 数据库直接单测执行下去。这种严格算来是属于集成测试的,等到后续如果业务变复杂了,可以再拆开测试。

单测代码最好不要启动 spring 容器,如果必要的话就要尽可能小范围的加载 spring 相关的东西,千万不用启动整个 spring 容器,这样会非常慢。

package tech.yummy.devops.hotwheel.business.controller.app;

import cn.kubeclub.core.data.DataProvider;
import mockit.Expectations;
import org.assertj.core.api.Assertions;
import org.junit.FixMethodOrder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.runners.MethodSorters;
import org.springframework.context.annotation.Profile;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.util.ReflectionTestUtils;
import tech.yummy.devops.hotwheel.base.BaseDatabaseMock;
import tech.yummy.devops.hotwheel.business.domain.app.App;
import tech.yummy.devops.hotwheel.business.service.app.impl.AppServiceImpl;
import tech.yummy.devops.hotwheel.core.page.TableDataInfo;
import tech.yummy.devops.hotwheel.core.page.TableSupport;
import tech.yummy.devops.hotwheel.infrastructure.dao.app.AppMapper;
import tech.yummy.devops.hotwheel.utils.ServletUtils;

import javax.annotation.Resource;

/**
 * @Author: ruijin.zhou@joymo.tech
 * @Description:
 * @Date: 2021/9/27 18:53
 */

/**
 * 贫血操作的增删改查
 */
@Sql({"classpath:sql/app.sql"})
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class AppControllerTest  extends BaseDatabaseMock {

    AppController appController = new AppController();

    @Resource
    private AppMapper appMapper;

    AppServiceImpl appService = new AppServiceImpl();
    App app = DataProvider.anyObject(App.class);

    @BeforeEach
    void setup(){
        // 将 mapper 对象反射注入 service 中
        ReflectionTestUtils.setField(appService, "appMapper", appMapper);
    }

    /**
     * 从 controller 层直接测试到数据库层,因为几乎没有业务逻辑,都是贫血操作。
     * 下面相当于 4 个 case 写一起了
     * 但凡有些业务逻辑的,不建议这样测。因为这相当于集成测试了。
     */
    @Test
    @Rollback
    void crudTest() {
        // 新增
        appService.insertApp(app);

        // 查列表
        bList();

        // 查询 ID
        selectAppById();

        // 删除
        deleteAppById();
    }

    /**
     * 查询列表
     */
    void bList() {
        new Expectations(ServletUtils.class) {
            {
                ServletUtils.getParameterToInt(TableSupport.PAGE_NUM); result = 1;
                ServletUtils.getParameterToInt(TableSupport.PAGE_SIZE); result = 10;
                ServletUtils.getParameter(TableSupport.ORDER_BY_COLUMN); result = null;
                ServletUtils.getParameter(TableSupport.IS_ASC); result = null;
            }
        };

        ReflectionTestUtils.setField(appController, "appService", appService);

        App param = new App();
        TableDataInfo result = appController.list(param);


        Assertions.assertThat(result).as("app 列表查询验证")
                .extracting(TableDataInfo::getRows)
                .asList()
                .hasSize(1)
                .first()
                // 校验所有非空字段
                .hasFieldOrPropertyWithValue("name",app.getName())
                .hasFieldOrPropertyWithValue("clusterId",app.getClusterId())
                .hasFieldOrPropertyWithValue("namespace",app.getNamespace());
    }

    /**
     * 查询详情
     */
    void selectAppById() {
        App result = appService.selectAppById(app.getId());
        Assertions.assertThat(result).as("app ID查询验证")
                .hasFieldOrPropertyWithValue("name",app.getName())
                .hasFieldOrPropertyWithValue("clusterId",app.getClusterId())
                .hasFieldOrPropertyWithValue("namespace",app.getNamespace());
    }

    /**
     * 删除
     */
    void deleteAppById() {
        appService.deleteAppById(app.getId());
    }
}

核心复杂业务 service 单测

复杂业务的一大困惑点是 一大堆需要 mock 的上下文,可以通过以下方式梳理:

    1. 了解 mock 跟 mockup 的区别
    2. 从整个业务去考虑单测的上下文,而不是每个方法各自考虑
    3. 上下文的封装抽取,不要杂在用例里面。
    1. mock 跟 mockup 的区别
      在之前的分享里面有谈单替身对象根据场景是有很多区分的:单元测试 - 交流分享

    在 jmockit 的语法里面:mock 的作用是替换,mockup 的作用是伪装(不是简单的给你代替了,而是要伪装成类似本来的你)

    1. 从整个业务去考虑单测的上下文
      复杂业务里面需要 mock 的上下文很多,如果每个用例单独进行,重复 mock 的对象会有很多。可以全局考虑,统一 mock。
    2. 上下文封装抽取

    输入图片说明

    用例代码

    package tech.yummy.devops.hotwheel.business.service.kubernetes;
    
    import cn.kubeclub.core.data.DataProvider;
    import lombok.extern.slf4j.Slf4j;
    import mockit.Injectable;
    import mockit.Tested;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.Test;
    import tech.yummy.devops.hotwheel.business.controller.app.vo.AppBriefVo;
    import tech.yummy.devops.hotwheel.business.controller.app.vo.PodVo;
    import tech.yummy.devops.hotwheel.business.service.cd.IAppClusterCdService;
    import tech.yummy.devops.hotwheel.business.service.cluster.IClusterService;
    import tech.yummy.devops.hotwheel.business.service.kubernetes.mockup.MockInformerService;
    import tech.yummy.devops.hotwheel.infrastructure.dao.cd.CdPipelineMapper;
    import tech.yummy.devops.hotwheel.infrastructure.dao.ci.CiPipelineRecordImageMapper;
    import tech.yummy.devops.hotwheel.infrastructure.k8s.ApiClientFactory;
    import tech.yummy.devops.hotwheel.infrastructure.util.AppUserRoleUtil;
    
    import java.util.List;
    
    /**
     * @Author: ruijin.zhou@joymo.tech
     * @Description:
     * @Date: 2021/9/29 10:33
     */
    @Slf4j
    class K8sDeploymentServiceTest {
    
        @Tested
        K8sDeploymentService k8sDeploymentService;
    
        @Injectable
        ApiClientFactory apiClientFactory;
        @Injectable
        IClusterService clusterService;
        @Injectable
        CdPipelineMapper cdPipelineMapper;
        @Injectable
        IAppClusterCdService appClusterCdService;
        @Injectable
        CiPipelineRecordImageMapper ciPipelineRecordImageMapper;
        @Injectable
        private AppUserRoleUtil appUserRoleUtil;
    
        @BeforeAll
        static void setup(){
            // 启动 informer 伪装类
            new MockInformerService();
        }
    
        @Test
        void selectPodsTest(){
    
            // 执行测试
            List<PodVo> selectPods = k8sDeploymentService.selectPods("dev-cluster","default", "hello-spring");
    
            //下面校验的参数是伪装类 MockInformerService 里面写死的了
            Assertions.assertThat(selectPods)
                    .hasSize(1)
                    .first()
            .hasFieldOrPropertyWithValue("name","hello-spring-6bd8c5bfbf-zpwdh")
                    .hasFieldOrPropertyWithValue("namespace","default")
                    .hasFieldOrPropertyWithValue("clusterName","dev-cluster")
                    .hasFieldOrPropertyWithValue("resourceVersion","78131792")
                    .hasFieldOrPropertyWithValue("showStatus","Running")
                    .hasFieldOrPropertyWithValue("conditionType","Ready");
        }
    
        @Test
        void getDeploymentInfoTest(){
            // 查询负载详情
            AppBriefVo appBriefVo = k8sDeploymentService.getDeploymentInfo("dev-cluster","default", "hello-spring");
    
            // 下面校验的参数是伪装类 MockInformerService 里面写死的了
            Assertions.assertThat(appBriefVo)
                    .isNotNull()
                    .hasFieldOrPropertyWithValue("name","hello-spring")
                    .hasFieldOrPropertyWithValue("namespace","default")
                    .hasFieldOrPropertyWithValue("clusterName","dev-cluster");
    
        }
    
    }

    Mock 对象

    package tech.yummy.devops.hotwheel.business.service.kubernetes.mockup;
    
    import io.kubernetes.client.informer.cache.Indexer;
    import io.kubernetes.client.openapi.models.V1Deployment;
    import io.kubernetes.client.openapi.models.V1Pod;
    import io.kubernetes.client.util.Yaml;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.compress.utils.Lists;
    
    import java.io.IOException;
    import java.lang.reflect.ParameterizedType;
    import java.lang.reflect.Type;
    import java.util.List;
    import java.util.Map;
    import java.util.function.Function;
    
    /**
     * @Author: ruijin.zhou@joymo.tech
     * @Description:
     * @Date: 2021/9/30 15:59
     */
    @Slf4j
    public class MockIndexerObject<T> implements Indexer {
        public Class<T> class2;
    
        public static String DEPLOYMENT_CLASS_NAME = "io.kubernetes.client.openapi.models.V1Deployment";
    
        public MockIndexerObject(Class<T> class2) {
            this.class2 = class2;
        }
        public static V1Pod getV1Pod(){
            V1Pod pod = null;
            try {
                pod = (V1Pod) Yaml.load(HELLO_SPRING_POD);
            } catch (IOException e) {
                log.warn("伪造 pod 对象失败",e);
            }
            return pod;
        }
        public static V1Deployment getV1Deployment(){
            V1Deployment deployment = null;
            try {
                deployment = (V1Deployment) Yaml.load(HELLO_SPRING_DEPLOYMENT);
            } catch (IOException e) {
                log.warn("伪造 deployment 对象失败",e);
            }
            return deployment;
        }
    
        @Override
        public void add(Object obj) {
    
        }
    
        @Override
        public void update(Object obj) {
    
        }
    
        @Override
        public void delete(Object obj) {
    
        }
    
        @Override
        public void replace(List list, String resourceVersion) {
    
        }
    
        @Override
        public void resync() {}
    
        @Override
        public List<String> listKeys() {
            return null;
        }
    
        @Override
        public Object get(Object obj) {
            if (POD_CLASS_NAME.equalsIgnoreCase(class2.getName())) {
                return getV1Pod();
            }
            if (DEPLOYMENT_CLASS_NAME.equalsIgnoreCase(class2.getName())) {
                return getV1Deployment();
            }
            return null;
        }
    
    
        @Override
        public Object getByKey(String key) {
            return get(key);
        }
    
        @Override
        public List<V1Pod> list() {
            return null;
        }
    
    
        @Override
        public List index(String indexName, Object obj) {
            return null;
        }
    
        @Override
        public List<String> indexKeys(String indexName, String indexKey) {
            return null;
        }
    
        @Override
        public List<T> byIndex(String indexName, String indexKey) {
            if (POD_CLASS_NAME.equalsIgnoreCase(class2.getName())) {
                List v1PodList = Lists.newArrayList();
                v1PodList.add(getV1Pod());
                return v1PodList;
            }
            if (DEPLOYMENT_CLASS_NAME.equalsIgnoreCase(class2.getName())) {
                List v1Deployment = Lists.newArrayList();
                v1Deployment.add(getV1Deployment());
                return v1Deployment;
            }
            return null;
        }
    
        @Override
        public Map<String>>> getIndexers() {
            return null;
        }
    
        @Override
        public void addIndexers(Map indexers) {
    
        }
    
        public static final String HELLO_SPRING_DEPLOYMENT = "kind: Deployment\n" +
                "apiVersion: apps/v1\n" +
                "metadata:\n" +
                "  name: hello-spring\n" +
                "  namespace: default\n" +
                "  selfLink: /apis/apps/v1/namespaces/default/deployments/hello-spring\n" +
                "  uid: 765c5a76-7799-4cc4-9742-8bc8aec5e2c7\n" +
                "  resourceVersion: '78135683'\n" +
                "  generation: 235\n" +
                "  creationTimestamp: '2021-08-18T08:58:01Z'\n" +
                "  labels:\n" +
                "    app: hello-spring\n" +
                "    cdPipelineId: '84'\n" +
                "    cdPipelineName: hello-spring\n" +
                "    cdRecordId: '1293'\n" +
                "    createEmp: yuqin.zhao\n" +
                "  annotations:\n" +
                "    deployment.kubernetes.io/revision: '227'\n" +
                "    kubectl.kubernetes.io/restartedAt: '2021-09-17T11:06:13+08:00'\n" +
                "spec:\n" +
                "  replicas: 2\n" +
                "  selector:\n" +
                "    matchLabels:\n" +
                "      app: hello-spring\n" +
                "  template:\n" +
                "    metadata:\n" +
                "      creationTimestamp: null\n" +
                "      labels:\n" +
                "        app: hello-spring\n" +
                "      annotations:\n" +
                "        kubectl.kubernetes.io/restartedAt: '2021-09-17T11:22:34+08:00'\n" +
                "    spec:\n" +
                "      volumes:\n" +
                "        - name: secret-1\n" +
                "          secret:\n" +
                "            secretName: hello-spring-secret\n" +
                "            items:\n" +
                "              - key: test-secret.txt\n" +
                "                path: test-secret.txt\n" +
                "            defaultMode: 420\n" +
                "      containers:\n" +
                "        - name: hello-spring\n" +
                "          image: 'swr.cn-north-4.myhuaweicloud.com/yummy-default/hello-spring-develop:77963'\n" +
                "          env:\n" +
                "            - name: SPRING_PROFILES_ACTIVE\n" +
                "              value: dev\n" +
                "            - name: EGG_SERVER_ENV\n" +
                "              value: dev\n" +
                "            - name: SERVER_SOURCE\n" +
                "              value: CCE\n" +
                "            - name: SWKAC_ENABLE\n" +
                "              value: 'true'\n" +
                "            - name: CCE_POD_NAME\n" +
                "              valueFrom:\n" +
                "                fieldRef:\n" +
                "                  apiVersion: v1\n" +
                "                  fieldPath: metadata.name\n" +
                "            - name: CCE_NAMESPACE\n" +
                "              valueFrom:\n" +
                "                fieldRef:\n" +
                "                  apiVersion: v1\n" +
                "                  fieldPath: metadata.namespace\n" +
                "            - name: CCE_POD_IP\n" +
                "              valueFrom:\n" +
                "                fieldRef:\n" +
                "                  apiVersion: v1\n" +
                "                  fieldPath: status.podIP\n" +
                "            - name: CCE_NODE_NAME\n" +
                "              valueFrom:\n" +
                "                fieldRef:\n" +
                "                  apiVersion: v1\n" +
                "                  fieldPath: spec.nodeName\n" +
                "            - name: test\n" +
                "              value: test\n" +
                "          resources:\n" +
                "            limits:\n" +
                "              cpu: '1'\n" +
                "              memory: 2Gi\n" +
                "            requests:\n" +
                "              cpu: 100m\n" +
                "              memory: 107374182400m\n" +
                "          volumeMounts:\n" +
                "            - name: secret-1\n" +
                "              mountPath: /test/test-secret.txt\n" +
                "              subPath: test-secret.txt\n" +
                "          livenessProbe:\n" +
                "            httpGet:\n" +
                "              path: /health\n" +
                "              port: 8080\n" +
                "              scheme: HTTP\n" +
                "            initialDelaySeconds: 5\n" +
                "            timeoutSeconds: 2\n" +
                "            periodSeconds: 10\n" +
                "            successThreshold: 1\n" +
                "            failureThreshold: 20\n" +
                "          readinessProbe:\n" +
                "            tcpSocket:\n" +
                "              port: 8080\n" +
                "            initialDelaySeconds: 10\n" +
                "            timeoutSeconds: 2\n" +
                "            periodSeconds: 30\n" +
                "            successThreshold: 1\n" +
                "            failureThreshold: 5\n" +
                "          lifecycle:\n" +
                "            preStop:\n" +
                "              exec:\n" +
                "                command:\n" +
                "                  - /bin/sh\n" +
                "                  - '-c'\n" +
                "                  - java -jar /home/appuser/java_pre_stop-1.0.jar '/home/appuser/gc.hprof' >> upload_obs.log\n" +
                "          terminationMessagePath: /dev/termination-log\n" +
                "          terminationMessagePolicy: File\n" +
                "          imagePullPolicy: Always\n" +
                "          securityContext:\n" +
                "            capabilities:\n" +
                "              add:\n" +
                "                - NET_BIND_SERVICE\n" +
                "              drop:\n" +
                "                - ALL\n" +
                "            allowPrivilegeEscalation: false\n" +
                "      restartPolicy: Always\n" +
                "      terminationGracePeriodSeconds: 15\n" +
                "      dnsPolicy: ClusterFirst\n" +
                "      automountServiceAccountToken: false\n" +
                "      securityContext:\n" +
                "        runAsUser: 1000\n" +
                "        runAsGroup: 1000\n" +
                "        runAsNonRoot: true\n" +
                "        fsGroup: 1000\n" +
                "      imagePullSecrets:\n" +
                "        - name: default-secret\n" +
                "      affinity:\n" +
                "        nodeAffinity:\n" +
                "          requiredDuringSchedulingIgnoredDuringExecution:\n" +
                "            nodeSelectorTerms:\n" +
                "              - matchExpressions:\n" +
                "                  - key: nodename\n" +
                "                    operator: NotIn\n" +
                "                    values:\n" +
                "                      - gitlab-runner\n" +
                "        podAntiAffinity:\n" +
                "          preferredDuringSchedulingIgnoredDuringExecution:\n" +
                "            - weight: 1\n" +
                "              podAffinityTerm:\n" +
                "                labelSelector:\n" +
                "                  matchExpressions:\n" +
                "                    - key: app\n" +
                "                      operator: In\n" +
                "                      values:\n" +
                "                        - hello-spring\n" +
                "                topologyKey: kubernetes.io/hostname\n" +
                "      schedulerName: default-scheduler\n" +
                "  strategy:\n" +
                "    type: RollingUpdate\n" +
                "    rollingUpdate:\n" +
                "      maxUnavailable: 1\n" +
                "      maxSurge: 2\n" +
                "  minReadySeconds: 5\n" +
                "  revisionHistoryLimit: 10\n" +
                "  progressDeadlineSeconds: 600\n" +
                "status:\n" +
                "  observedGeneration: 235\n" +
                "  replicas: 2\n" +
                "  updatedReplicas: 2\n" +
                "  readyReplicas: 2\n" +
                "  availableReplicas: 2\n" +
                "  conditions:\n" +
                "    - type: Available\n" +
                "      status: 'True'\n" +
                "      lastUpdateTime: '2021-09-29T06:09:47Z'\n" +
                "      lastTransitionTime: '2021-09-29T06:09:47Z'\n" +
                "      reason: MinimumReplicasAvailable\n" +
                "      message: Deployment has minimum availability.\n" +
                "    - type: Progressing\n" +
                "      status: 'True'\n" +
                "      lastUpdateTime: '2021-09-30T03:26:48Z'\n" +
                "      lastTransitionTime: '2021-09-29T06:29:55Z'\n" +
                "      reason: NewReplicaSetAvailable\n" +
                "      message: ReplicaSet \"hello-spring-fdd9cf587\" has successfully progressed.\n";
    
        public static final String HELLO_SPRING_POD = "apiVersion: v1\n" +
                "kind: Pod\n" +
                "metadata:\n" +
                "  name: hello-spring-6bd8c5bfbf-zpwdh\n" +
                "  generateName: hello-spring-6bd8c5bfbf-\n" +
                "  namespace: default\n" +
                "  selfLink: /api/v1/namespaces/default/pods/hello-spring-6bd8c5bfbf-zpwdh\n" +
                "  uid: 953b0fc8-0365-49f4-b509-73533f1069c4\n" +
                "  resourceVersion: '78131792'\n" +
                "  creationTimestamp: '2021-09-30T03:18:15Z'\n" +
                "  labels:\n" +
                "    app: hello-spring\n" +
                "    pod-template-hash: 6bd8c5bfbf\n" +
                "    skywalking: enabled\n" +
                "    skywalking-volume: skywalking-8bf807be\n" +
                "spec:\n" +
                "  volumes:\n" +
                "    - name: secret-1\n" +
                "      secret:\n" +
                "        secretName: hello-spring-secret\n" +
                "        items:\n" +
                "          - key: test-secret.txt\n" +
                "            path: test-secret.txt\n" +
                "        defaultMode: 420\n" +
                "    - name: skywalking-8bf807be\n" +
                "      emptyDir:\n" +
                "        sizeLimit: 200Mi\n" +
                "  initContainers:\n" +
                "    - name: skywalking-init-8bf807be\n" +
                "      image: 'swr.cn-north-4.myhuaweicloud.com/joymo-archcenter/skywalking-agent:77743'\n" +
                "      env:\n" +
                "        - name: AGENT_HOME\n" +
                "          value: /opt/skywalking\n" +
                "      resources: {}\n" +
                "      volumeMounts:\n" +
                "        - name: skywalking-8bf807be\n" +
                "          mountPath: /opt/skywalking\n" +
                "      terminationMessagePath: /dev/termination-log\n" +
                "      terminationMessagePolicy: File\n" +
                "      imagePullPolicy: IfNotPresent\n" +
                "  containers:\n" +
                "    - name: hello-spring\n" +
                "      image: 'swr.cn-north-4.myhuaweicloud.com/yummy-default/hello-spring-develop:77954'\n" +
                "      env:\n" +
                "        - name: SPRING_PROFILES_ACTIVE\n" +
                "          value: dev\n" +
                "        - name: EGG_SERVER_ENV\n" +
                "          value: dev\n" +
                "        - name: SERVER_SOURCE\n" +
                "          value: CCE\n" +
                "        - name: SWKAC_ENABLE\n" +
                "          value: 'true'\n" +
                "        - name: CCE_POD_NAME\n" +
                "          valueFrom:\n" +
                "            fieldRef:\n" +
                "              apiVersion: v1\n" +
                "              fieldPath: metadata.name\n" +
                "        - name: CCE_NAMESPACE\n" +
                "          valueFrom:\n" +
                "            fieldRef:\n" +
                "              apiVersion: v1\n" +
                "              fieldPath: metadata.namespace\n" +
                "        - name: CCE_POD_IP\n" +
                "          valueFrom:\n" +
                "            fieldRef:\n" +
                "              apiVersion: v1\n" +
                "              fieldPath: status.podIP\n" +
                "        - name: CCE_NODE_NAME\n" +
                "          valueFrom:\n" +
                "            fieldRef:\n" +
                "              apiVersion: v1\n" +
                "              fieldPath: spec.nodeName\n" +
                "        - name: test\n" +
                "          value: test\n" +
                "        - name: JAVA_TOOL_OPTIONS\n" +
                "          value: '-javaagent:/opt/skywalking/skywalking-agent.jar'\n" +
                "        - name: SW_AGENT_COLLECTOR_BACKEND_SERVICES\n" +
                "          value: 'skywalking-oap-rpc.default.svc.cluster.local:11800'\n" +
                "        - name: SW_AGENT_NAME\n" +
                "          value: hello-spring\n" +
                "        - name: SW_JDBC_TRACE_SQL_PARAMETERS\n" +
                "          value: 'true'\n" +
                "        - name: SW_DUBBO_COLLECT_CONSUMER_ARGUMENTS\n" +
                "          value: 'true'\n" +
                "        - name: SW_DUBBO_COLLECT_PROVIDER_ARGUMENTS\n" +
                "          value: 'true'\n" +
                "      resources:\n" +
                "        limits:\n" +
                "          cpu: '1'\n" +
                "          memory: 2Gi\n" +
                "        requests:\n" +
                "          cpu: 100m\n" +
                "          memory: 107374182400m\n" +
                "      volumeMounts:\n" +
                "        - name: secret-1\n" +
                "          mountPath: /test/test-secret.txt\n" +
                "          subPath: test-secret.txt\n" +
                "        - name: skywalking-8bf807be\n" +
                "          mountPath: /opt/skywalking\n" +
                "      livenessProbe:\n" +
                "        httpGet:\n" +
                "          path: /health\n" +
                "          port: 8080\n" +
                "          scheme: HTTP\n" +
                "        initialDelaySeconds: 5\n" +
                "        timeoutSeconds: 2\n" +
                "        periodSeconds: 10\n" +
                "        successThreshold: 1\n" +
                "        failureThreshold: 20\n" +
                "      readinessProbe:\n" +
                "        tcpSocket:\n" +
                "          port: 8080\n" +
                "        initialDelaySeconds: 10\n" +
                "        timeoutSeconds: 2\n" +
                "        periodSeconds: 30\n" +
                "        successThreshold: 1\n" +
                "        failureThreshold: 5\n" +
                "      lifecycle:\n" +
                "        preStop:\n" +
                "          exec:\n" +
                "            command:\n" +
                "              - /bin/sh\n" +
                "              - '-c'\n" +
                "              - java -jar /home/appuser/java_pre_stop-1.0.jar '/home/appuser/gc.hprof' >> upload_obs.log\n" +
                "      terminationMessagePath: /dev/termination-log\n" +
                "      terminationMessagePolicy: File\n" +
                "      imagePullPolicy: Always\n" +
                "      securityContext:\n" +
                "        capabilities:\n" +
                "          add:\n" +
                "            - NET_BIND_SERVICE\n" +
                "          drop:\n" +
                "            - ALL\n" +
                "        allowPrivilegeEscalation: false\n" +
                "  restartPolicy: Always\n" +
                "  terminationGracePeriodSeconds: 15\n" +
                "  dnsPolicy: ClusterFirst\n" +
                "  serviceAccountName: default\n" +
                "  serviceAccount: default\n" +
                "  automountServiceAccountToken: false\n" +
                "  nodeName: 10.88.171.106\n" +
                "  securityContext:\n" +
                "    runAsUser: 1000\n" +
                "    runAsGroup: 1000\n" +
                "    runAsNonRoot: true\n" +
                "    fsGroup: 1000\n" +
                "  imagePullSecrets:\n" +
                "    - name: default-secret\n" +
                "  affinity:\n" +
                "    nodeAffinity:\n" +
                "      requiredDuringSchedulingIgnoredDuringExecution:\n" +
                "        nodeSelectorTerms:\n" +
                "          - matchExpressions:\n" +
                "              - key: nodename\n" +
                "                operator: NotIn\n" +
                "                values:\n" +
                "                  - gitlab-runner\n" +
                "    podAntiAffinity:\n" +
                "      preferredDuringSchedulingIgnoredDuringExecution:\n" +
                "        - weight: 1\n" +
                "          podAffinityTerm:\n" +
                "            labelSelector:\n" +
                "              matchExpressions:\n" +
                "                - key: app\n" +
                "                  operator: In\n" +
                "                  values:\n" +
                "                    - hello-spring\n" +
                "            topologyKey: kubernetes.io/hostname\n" +
                "  schedulerName: default-scheduler\n" +
                "  tolerations:\n" +
                "    - key: node.kubernetes.io/not-ready\n" +
                "      operator: Exists\n" +
                "      effect: NoExecute\n" +
                "      tolerationSeconds: 300\n" +
                "    - key: node.kubernetes.io/unreachable\n" +
                "      operator: Exists\n" +
                "      effect: NoExecute\n" +
                "      tolerationSeconds: 300\n" +
                "  priority: 0\n" +
                "  dnsConfig:\n" +
                "    options:\n" +
                "      - name: single-request-reopen\n" +
                "        value: ''\n" +
                "      - name: timeout\n" +
                "        value: '2'\n" +
                "  enableServiceLinks: true\n" +
                "status:\n" +
                "  phase: Running\n" +
                "  conditions:\n" +
                "    - type: Initialized\n" +
                "      status: 'True'\n" +
                "      lastProbeTime: null\n" +
                "      lastTransitionTime: '2021-09-30T03:18:17Z'\n" +
                "    - type: Ready\n" +
                "      status: 'True'\n" +
                "      lastProbeTime: null\n" +
                "      lastTransitionTime: '2021-09-30T03:18:45Z'\n" +
                "    - type: ContainersReady\n" +
                "      status: 'True'\n" +
                "      lastProbeTime: null\n" +
                "      lastTransitionTime: '2021-09-30T03:18:45Z'\n" +
                "    - type: PodScheduled\n" +
                "      status: 'True'\n" +
                "      lastProbeTime: null\n" +
                "      lastTransitionTime: '2021-09-30T03:18:15Z'\n" +
                "  hostIP: 10.88.171.106\n" +
                "  podIP: 172.20.3.52\n" +
                "  podIPs:\n" +
                "    - ip: 172.20.3.52\n" +
                "  startTime: '2021-09-30T03:18:15Z'\n" +
                "  initContainerStatuses:\n" +
                "    - name: skywalking-init-8bf807be\n" +
                "      state:\n" +
                "        terminated:\n" +
                "          exitCode: 0\n" +
                "          reason: Completed\n" +
                "          startedAt: '2021-09-30T03:18:17Z'\n" +
                "          finishedAt: '2021-09-30T03:18:17Z'\n" +
                "          containerID: 'docker://a1c314484630d088c2d89b0e36bcfb4a9d349bfaf29563b4b4b59d1fe63da611'\n" +
                "      lastState: {}\n" +
                "      ready: true\n" +
                "      restartCount: 0\n" +
                "      image: 'swr.cn-north-4.myhuaweicloud.com/joymo-archcenter/skywalking-agent:77743'\n" +
                "      imageID: 'docker-pullable://swr.cn-north-4.myhuaweicloud.com/joymo-archcenter/skywalking-agent@sha256:1952a45b4ad3aeb718c0bf849af10b576012d210ca28d40084b40c9fcf0b5d26'\n" +
                "      containerID: 'docker://a1c314484630d088c2d89b0e36bcfb4a9d349bfaf29563b4b4b59d1fe63da611'\n" +
                "  containerStatuses:\n" +
                "    - name: hello-spring\n" +
                "      state:\n" +
                "        running:\n" +
                "          startedAt: '2021-09-30T03:18:20Z'\n" +
                "      lastState: {}\n" +
                "      ready: true\n" +
                "      restartCount: 0\n" +
                "      image: 'swr.cn-north-4.myhuaweicloud.com/yummy-default/hello-spring-develop:77954'\n" +
                "      imageID: 'docker-pullable://swr.cn-north-4.myhuaweicloud.com/yummy-default/hello-spring-develop@sha256:3e492c06a40f8259ed6a4a37de136c1b9da9d6a260da854b93bb8c50bd4c95e1'\n" +
                "      containerID: 'docker://63c4c222224caf2f25de57d784baa64b5045ff8ba945508a525a27131d9ce9f7'\n" +
                "      started: true\n" +
                "  qosClass: Burstable\n";
    }
    package tech.yummy.devops.hotwheel.business.service.kubernetes.mockup;
    
    import io.kubernetes.client.informer.SharedIndexInformer;
    import io.kubernetes.client.informer.cache.Lister;
    import io.kubernetes.client.openapi.models.V1Deployment;
    import io.kubernetes.client.openapi.models.V1Pod;
    import lombok.extern.slf4j.Slf4j;
    import mockit.Mock;
    import mockit.MockUp;
    import tech.yummy.devops.hotwheel.business.service.kubernetes.AppPodListLister;
    import tech.yummy.devops.hotwheel.business.service.kubernetes.InformerService;
    
    import static tech.yummy.devops.hotwheel.business.service.kubernetes.InformerService.APP_PODLIST_INDEXER;
    
    /**
     * @Author: ruijin.zhou@joymo.tech
     * @Description: k8s informerservice 伪装类
     * @Date: 2021/9/29 18:06
     */
    @Slf4j
    public final class MockInformerService extends MockUp<InformerService> {
    
    
    
        @Mock
        public static AppPodListLister<V1Pod> getAppPodListLister(String clusterName, String namespace) {
            MockSharedIndexInformer<V1Pod> indexInformer = new MockSharedIndexInformer<V1Pod>(V1Pod.class);
            return new AppPodListLister&lt;>(indexInformer.getIndexer(), namespace, APP_PODLIST_INDEXER);
        }
    
    
        @Mock
        public static Lister<V1Deployment> getDeploymentLister(String clusterName, String namespace) {
            MockSharedIndexInformer<V1Deployment> indexInformer =  new MockSharedIndexInformer<V1Deployment>(V1Deployment.class);
            return new Lister&lt;>(indexInformer.getIndexer(), namespace);
        }
    }
    package tech.yummy.devops.hotwheel.business.service.kubernetes.mockup;
    
    import io.kubernetes.client.informer.ResourceEventHandler;
    import io.kubernetes.client.informer.SharedIndexInformer;
    import io.kubernetes.client.informer.cache.Indexer;
    import io.kubernetes.client.openapi.models.V1Deployment;
    import io.kubernetes.client.openapi.models.V1Pod;
    
    import java.lang.reflect.ParameterizedType;
    import java.lang.reflect.Type;
    import java.util.Map;
    
    /**
     * @Author: ruijin.zhou@joymo.tech
     * @Description: shareIndex 伪造
     * @Date: 2021/9/30 15:56
     */
    public class MockSharedIndexInformer<T> implements SharedIndexInformer {
        public Class<T> class2;
        public MockSharedIndexInformer(Class<T> class2) {
            this.class2 = class2;
        }
    
        @Override
        public void addIndexers(Map indexers) {
    
        }
    
        @Override
        public Indexer<T> getIndexer() {
            Indexer<T> indexer = new MockIndexerObject<T>(class2);
            return indexer;
        }
    
        @Override
        public void addEventHandler(ResourceEventHandler handler) {
    
        }
    
        @Override
        public void addEventHandlerWithResyncPeriod(ResourceEventHandler handler, long resyncPeriod) {
    
        }
    
        @Override
        public void run() {
    
        }
    
        @Override
        public void stop() {
    
        }
    
        @Override
        public boolean hasSynced() {
            return false;
        }
    
        @Override
        public String lastSyncResourceVersion() {
            return null;
        }
    }

    文件上传和下载

    利用 mockMvc 起了 http 服务真实触发调用
    //业务代码
    
    /**
     * 文件上传
     *
     * @param file文件
     * @return CommonAttachmentBO 附件BO对象
     */
    @PostMapping(value = "/upload")
    public ResultInfo upload(@RequestParam MultipartFile file) throws IOException {
        ParameterCheckUtil.notNull(file, RetMsgConsts.JOB_SQL_FILE_EMPTY);
        UploadFileDTO uploadFileDTO = CommonFileTools.transferToUploadFileDTO(file);
        return ResultInfo.success(taskSqlFileService.upload(uploadFileDTO));
    }
    
    @Autowired
    private WebApplicationContext context;
    
    private MockMvc mockMvc;
    
    /**
     * 在每次测试执行前构建mvc环境
     */
    @BeforeEach
    public void before() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }
    
    /**
     * 文件上传
     */
    @Test
    void upload() {
        try {
            String url = "/axure/attachment/upload";
            File file = File.createTempFile&#40;"test", ".txt"&#41;;
            MockMultipartFile multipartFile = new MockMultipartFile&#40;"uploadTest", file.getName(&#41;,
                    MediaType.TEXT_PLAIN_VALUE, new FileInputStream(file));
            MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.fileUpload(url)
                    .file&#40;multipartFile&#41;.header(CommonConstant.AUTH_TOKEN, "123456"))
                    .andReturn();
            MockHttpServletResponse response = mvcResult.getResponse();
            int status = response.getStatus();
            String contentAsString = response.getContentAsString();
            ResultInfo resultInfo = JSON.parseObject(contentAsString, ResultInfo.class);
            log.info("状态码::" + status);
            log.info("返回结果:" + JsonUtils.format(contentAsString));
            Assertions.assertEquals(Boolean.TRUE, resultInfo.getSuccess());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 文件下载
     */
    @Test
    void download(){
        try {
            String requestUrl = "/common/file/get/20211004114755717168124020000001";
            String outputFileUrl = "E:\\test2.sql";
            MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post(requestUrl)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .header(CommonConstant.AUTH_TOKEN, "123456")
                    .accept(MediaType.MULTIPART_FORM_DATA_VALUE))
                    .andReturn();
            MockHttpServletResponse response = mvcResult.getResponse();
            FileOutputStream file = new FileOutputStream(outputFileUrl);
            file.write(response.getContentAsByteArray());
            Assertions.assertNotNull(file);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    并行测试

    依赖junit5.3的并行测试

    默认情况下,junit测试是在一个线程中串行执行的,从5.3开始支持并行测试。
    首先需要在resources创建配置文件junit-platform.properties,添加配置如下参数:

    
    #是否允许存在并发测试
    junit.jupiter.execution.parallel.enabled = true
    #设置单元测试默认执行方式:concurrent并发执行,same_thread 串行执行,默认same_thread
    unit.jupiter.execution.parallel.mode.default = concurrent
    #设置单元测试类执行方式:concurrent并发执行,same_thread 串行执行,
    #不设置时根据junit.jupiter.execution.parallel.mode.default同值
    junit.jupiter.execution.parallel.mode.classes.default = same_thread

    配置的默认执行模式会作用于测试树上的每个节点,但是有几个需要主要的例外。
    首先就是针对于测试声明周期的配置(默认是方法级别的)如果设置为类级别,则必须保证测试类是线程安全的,另外对于设置测试方法的执行顺序的话,这个与并发测试是互相矛盾的。
    在以上这两种情况下,必须明确在测试类或方法上面添加注解@Execution(CONCURRENT),否则也不会按照并发模式进行测试的。
    采用以上的模式,所有的测试类和测试方法都是并发来执行的。

    单元测试类

    
    @Slf4j
    public class Service1Test {
    
        @Test
        public void method1(){
            log.info("执行测试:Service1Test.method1,线程:"+Thread.currentThread().getName());
            //empExtService.initEmpExtList();
            Thread.sleep(3000)
            Assert.assertTrue(true);
        }
    
        @Test
        public void method2(){
            log.info("执行测试:Service1Test.method2,线程:"+Thread.currentThread().getName());
            //empExtService.initEmpExtList();
            Assert.assertTrue(true);
        }
    }

    当不开启并行测试时,执行结果为:

    。。。.method1(Service1Test.java:26) - 执行测试:Service1Test.method1,线程:main
    。。。.method2(Service1Test.java:33) - 执行测试:Service1Test.method2,线程:main

    当开启后,方法并行时执行结果:(可看到执行的线程不是同个)

    method1(Service1Test.java:26) - 执行测试:Service1Test.method1,线程:ForkJoinPool-1-worker-2
    
    method2(Service1Test.java:33) - 执行测试:Service1Test.method2,线程:ForkJoinPool-1-worker-1

    2. 依赖surefire插件的并行测试

    Maven-surefire-plugin 是一个用于mvn 生命周期的测试阶段的插件,可以通过一些参数设置方便的在junit下对测试阶段进行自定义。
    Maven运行测试用例时,是通过调用maven的surefire插件并fork一个子进程来执行用例的。
    在surefire版本2.14之前使用forkMode控制多进程,现在使用forkCount控制。
    在surefire插件配置<configuration>中添加如下配置:
    需要注意的是,这边是以测试类为执行单位,并发执行。
    (目前的执行环境:jmockit:1.46;junit-jupiter-api:5.4.0;junit-jupiter-engine:5.4.0;maven-surefire-plugin:3.0.0-M5)

    &lt;!--forkCount启动的fork的进程数 --&gt;
                        <forkCount>3</forkCount>
    &lt;!--表示一个测试进程执行完了之后是杀掉还是重用来继续执行后续的测试 --&gt;
                        <reuseForks>true</reuseForks>

    参数化测试

    和Junit4相比,Junit5框架更多在向测试平台演进。其核心组成也从以前的一个Junit的jar包更换成由多个模块组成。

    • junit-jupiter-engine: Junit的核心测试引擎
    • junit-jupiter-params: 编写参数化测试所需要的依赖包
    • junit-platform-launcher: 从IDE(InteliJ/Eclipses)等运行时所需要的启动器

    参数源:
    如果待测试的输入和输出是一组数据: 可以把测试数据组织起来 用不同的测试数据调用相同的测试方法
    参数化测试和普通测试稍微不同的地方在于,一个测试方法需要接收至少一个参数,然后,传入一组参数反复运行。
    JUnit5提供了一个@ParameterizedTest注解,用来进行参数化测试。
    假设我们想对Math.abs()进行测试,先用一组正数进行测试:

    @ParameterizedTest
    @ValueSource(ints = { 0, 1, 5, 100 })
    void testAbs(int x) {
        assertEquals(x, Math.abs(x));
    }

    再用一组负数进行测试:

    @ParameterizedTest
    @ValueSource(ints = { -1, -5, -100 })
    void testAbsNegative(int x) {
        assertEquals(-x, Math.abs(x));
    }

    参数化测试的注解是@ParameterizedTest,而不是普通的@Test。
    假设我们自己编写了一个StringUtils.capitalize()方法,它会把字符串的第一个字母变为大写,后续字母变为小写:

    public class StringUtils {
        public static String capitalize(String s) {
            if (s.length() == 0) {
                return s;
            }
            return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
        }
    }

    要用参数化测试的方法来测试,我们不但要给出输入,还要给出预期输出。因此,测试方法至少需要接收两个参数:

    @ParameterizedTest
    void testCapitalize(String input, String result) {
        assertEquals(result, StringUtils.capitalize(input));
    }

    现在问题来了:参数如何传入?
    最简单的方法是通过 @MethodSource注解,它允许我们编写一个同名的静态方法来提供测试参数:

    @ParameterizedTest
    @MethodSource()
    void testCapitalize(String input, String result) {
        assertEquals(result, StringUtils.capitalize(input));
    }
    
    static List<Arguments> testCapitalize() {
        return List.of( // arguments:
                Arguments.arguments("abc", "Abc"), //
                Arguments.arguments("APPLE", "Apple"), //
                Arguments.arguments("gooD", "Good"));
    }

    上面的代码很容易理解:静态方法testCapitalize()返回了一组测试参数,每个参数都包含两个String,正好作为测试方法的两个参数传入。

    参数转换

    Junit5 支持对提供给 @ParameterizedTest 的参数进行扩展的原始转换。
    例如,使用 @ValueSource(ints ={1,2,3}) 注释的参数化测试可以声明为不仅接受int类型的参数,而且接受long、float或double类型的参数。
    例如,如果 @ParameterizedTest 声明类型TimeUnit的参数,而声明的源提供的实际类型是字符串,
    则该字符串将自动转换为相应的 TimeUnit enum常量。

    @ParameterizedTest
    @ValueSource(strings = "SECONDS")
    void testWithImplicitArgumentConversion(TimeUnit argument) {
        assertNotNull(argument.name());
    }

    显式转换

    与使用隐式参数转换不同,您可以使用@ConvertWith注释显式地指定一个ArgumentConverter来使用,如下面的示例所示。

    @ParameterizedTest
    @EnumSource(TimeUnit.class)
    void testWithExplicitArgumentConversion(
            @ConvertWith(ToStringArgumentConverter.class) String argument) {
    
        assertNotNull(TimeUnit.valueOf(argument));
    }
    
    public class ToStringArgumentConverter extends SimpleArgumentConverter {
    
        @Override
        protected Object convert(Object source, Class&lt;?&gt; targetType) {
            assertEquals(String.class, targetType, "Can only convert to String");
            return String.valueOf(source);
        }
    }
    

    参数聚合(Argument Aggregation)
    默认情况下,提供给@ParameterizedTest方法的每个参数都对应于单个方法参数。
    因此,期望提供大量参数的参数源可能导致大方法签名。
    在这种情况下,可以使用ArgumentsAccessor而不是使用多个参数。
    使用这个API,可以通过传递给测试方法的单个参数访问提供的参数

    @ParameterizedTest
    @CsvSource({
        "Jane, Doe, F, 1990-05-20",
        "John, Doe, M, 1990-10-22"
    })
    void testWithArgumentsAccessor(ArgumentsAccessor arguments) {
        Person person = new Person(arguments.getString(0),
                                   arguments.getString(1),
                                   arguments.get(2, Gender.class),
                                   arguments.get(3, LocalDate.class));
    
        if (person.getFirstName().equals("Jane")) {
            assertEquals(Gender.F, person.getGender());
        }
        else {
            assertEquals(Gender.M, person.getGender());
        }
        assertEquals("Doe", person.getLastName());
        assertEquals(1990, person.getDateOfBirth().getYear());
    }
    

    Controller session

    @RestController
    @RequestMapping("/user")
    public class UserController {
    
        //读取session
        @GetMapping("/get")
        public String getsess(HttpServletRequest request) {
            HttpSession session=request.getSession();
            String username = (String)session.getAttribute("username");
            System.out.println("session username:"+username);
            if (username == null) {
                return "";
            } else {
                return username;
            }
        }
    
        //设置session
        @GetMapping("/set")
        public String setSess(@RequestParam("userName")String userName, HttpServletRequest request) {
            HttpSession session=request.getSession();
            session.setAttribute("username", userName);
            return userName;
        }
    }
    @AutoConfigureMockMvc
    @SpringBootTest
    class UserControllerTest {
    
        @Autowired
        private UserController userController;
    
        @Autowired
        private MockMvc mockMvc;
    
        private static MockHttpSession session;
    
        @BeforeAll
        public static void setupMockMvc() {
            session = new MockHttpSession();
            session.setAttribute("username", "刘新漳");
        }
        @Test
        @DisplayName("测试get用户名,有session")
        void getTest() throws Exception {
            MvcResult mvcResult = mockMvc.perform(get("/user/get")
                    .session(session)
                    .contentType(new MediaType("application", "x-www-form-urlencoded")))
                    .andReturn();
            String content = mvcResult.getResponse().getContentAsString();
            assertThat(content, equalTo("刘新漳"));
        }
    
        @Test
        @DisplayName("测试get用户名,无session")
        void getTestFail() throws Exception {
            MvcResult mvcResult = mockMvc.perform(get("/user/get")
                    .contentType(new MediaType("application", "x-www-form-urlencoded")))
                    .andReturn();
            String content = mvcResult.getResponse().getContentAsString();
            assertThat(content, equalTo(""));
        }
    
        @Test
        @DisplayName("测试set session")
        void setTest() throws Exception {
            String name="liu";
            MvcResult mvcResult = mockMvc.perform(get("/user/set?userName="+name)
                    .session(session)
                    .contentType(new MediaType("application", "x-www-form-urlencoded")))
                    .andReturn();
            String content = mvcResult.getResponse().getContentAsString();
            assertThat(content, equalTo("liu"));
        }
    }

    REDIS 测试基类

    主要思路为启动一个本地Redis Server,模拟远程Redis服务。
    由于依赖开源项目, 具体支持的指令如下,若执行不支持,可以使用MockUp方法或者自行扩展开源项目
    https://github.com/fppt/jedis-mock/tree/master/src/main/java/com/github/fppt/jedismock/operations

    添加Maven依赖

    &lt;!-- https://github.com/fppt/jedis-mock --&gt;
    <dependency>
        <groupId>com.github.fppt</groupId>
        <artifactId>jedis-mock</artifactId>
        <version>0.1.22</version>
        <scope>test</scope>
    </dependency>

    基类代码

    package tech.yummy.devops.hotwheel.base;
    
    import com.github.fppt.jedismock.RedisServer;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.serializer.StringRedisSerializer;
    import tech.yummy.devops.hotwheel.infrastructure.redis.RedisCache;
    
    import java.io.IOException;
    import java.net.ServerSocket;
    
    /**
     * @Description: RedisServer 全局
     * @Author: xxwu
     * @Date: 2021/9/28 14:32
     */
    @Slf4j
    public class RedisServerHolder {
    
        private static RedisServer redisServer;
    
        private static RedisConnectionFactory redisConnectionFactory;
    
        private static RedisTemplate redisTemplate;
    
        private static RedisCache redisCache;
    
        public static RedisServer getRedisServer() {
            if(redisServer == null){
                synchronized (RedisServerHolder.class){
                    if(redisServer == null){
                        // 随机获取未被占用端口
                        ServerSocket socket = null;
                        try {
                            long st = System.currentTimeMillis();
                            socket = new ServerSocket(0);
                            int port = socket.getLocalPort();
                            socket.close();
    
                            // 启动一个RedisServer
                            redisServer = new RedisServer(port);
                            redisServer.start();
    
                            log.info(" ======= 启动RedisServer成功, 耗时{}ms ======= ", System.currentTimeMillis() - st);
                        } catch (IOException e) {
                            log.error(" ======= 启动RedisServer出错 ======= ", e);
                        }
                    }
                }
            }
            return redisServer;
        }
    
        public static RedisConnectionFactory getRedisConnectionFactory() {
            if(redisConnectionFactory == null){
                synchronized (RedisServerHolder.class){
                    if(redisConnectionFactory == null){
                        JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
                        RedisServer redisServer = getRedisServer();
    
                        jedisConnectionFactory.setHostName(redisServer.getHost());
                        jedisConnectionFactory.setPort(redisServer.getBindPort());
    
                        redisConnectionFactory = jedisConnectionFactory;
                    }
                }
            }
            return redisConnectionFactory;
        }
    
        public static RedisTemplate getRedisTemplate() {
            if(redisTemplate == null) {
                synchronized (RedisServerHolder.class) {
                    if (redisTemplate == null) {
                        RedisTemplate<String> stringObjectRedisTemplate = new RedisTemplate&lt;>();
                        // 配置连接工厂
                        stringObjectRedisTemplate.setConnectionFactory(getRedisConnectionFactory());
    
                        StringRedisSerializer serializer = new StringRedisSerializer();
                        stringObjectRedisTemplate.setHashKeySerializer(serializer);
                        stringObjectRedisTemplate.setKeySerializer(serializer);
    
                        redisTemplate = stringObjectRedisTemplate;
                        redisTemplate.afterPropertiesSet();
    
                        // 首次执行会消耗一些时间, 算预热
                        redisTemplate.opsForValue().set("xxxx","xxxx");
                        redisTemplate.opsForValue().get("xxxx");
                    }
                }
            }
            return redisTemplate;
        }
    
        public static RedisCache getRedisCache() {
            if(redisCache == null) {
                synchronized (RedisServerHolder.class) {
                    if (redisCache == null) {
                        redisCache = new RedisCache();
                        redisCache.redisTemplate = getRedisTemplate();
                    }
                }
            }
            return redisCache;
        }
    }

    使用方法

    package tech.yummy.devops.hotwheel.business.controller.app;
    
    import lombok.extern.slf4j.Slf4j;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.springframework.context.annotation.Import;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.test.context.ActiveProfiles;
    import org.springframework.test.context.junit.jupiter.SpringExtension;
    import tech.yummy.devops.hotwheel.base.RedisMockConfig;
    import tech.yummy.devops.hotwheel.base.RedisServerHolder;
    import tech.yummy.devops.hotwheel.infrastructure.redis.RedisCache;
    
    /**
     * @author jinjia.wu
     * @date 2021/9/25
     */
    @Slf4j
    class RedisTest {
    
        private RedisTemplate redisTemplate = RedisServerHolder.getRedisTemplate();
    
        private RedisCache redisCache = RedisServerHolder.getRedisCache();
    
    
        @Test
        void templateTest() {
    
            String key = "xxwu";
            String setValue = "20210925";
    
            // =========== value ==========
            redisTemplate.opsForValue().set(key, setValue);
            String getValue = (String) redisTemplate.opsForValue().get(key);
            Assertions.assertThat(getValue)
                    .isNotBlank()
                    .isEqualTo(setValue);
            // 删除key, 否则key已存在影响下面的测试
            redisTemplate.delete(key);
    
            // =========== hash ==========
            redisTemplate.opsForHash().put(key, key, setValue);
            getValue = (String) redisTemplate.opsForHash().get(key, key);
            Assertions.assertThat(getValue)
                    .isNotBlank()
                    .isEqualTo(setValue);
            redisTemplate.delete(key);
    
            // =========== set ==========
            redisTemplate.opsForSet().add(key, setValue);
            Boolean member = redisTemplate.opsForSet().isMember(key, setValue);
            Assertions.assertThat(member)
                    .isNotNull()
                    .isEqualTo(Boolean.TRUE);
            redisTemplate.delete(key);
    
        }
    
    
        @Test
        void redisCacheTest() {
    
            String key = "xxwu";
            String setValue = "20210925";
    
            redisCache.setCacheObject(key, setValue);
            String getValue = redisCache.getCacheObject(key);
            Assertions.assertThat(getValue)
                    .isNotBlank()
                    .isEqualTo(setValue);
            // 删除key, 否则key已存在影响下面的测试
            redisCache.deleteObject(key);
    
    
            // =========== hash ==========
            redisCache.setCacheMapValue(key, key, setValue);
            getValue = (String) redisCache.getCacheMapValue(key, key);
            Assertions.assertThat(getValue)
                    .isNotBlank()
                    .isEqualTo(setValue);
            redisCache.deleteObject(key);
    
        }
    }

    ES 单测例子

    package tech.yummy.devops.hotwheel.business.service.system.impl;
    
    import cn.kubeclub.core.data.DataProvider;
    import mockit.Injectable;
    import mockit.Mock;
    import mockit.MockUp;
    import mockit.Tested;
    import mockit.Verifications;
    import org.assertj.core.api.Assertions;
    import org.elasticsearch.search.aggregations.Aggregations;
    import org.junit.jupiter.api.Test;
    import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
    import org.springframework.data.elasticsearch.core.SearchHit;
    import org.springframework.data.elasticsearch.core.SearchHits;
    import org.springframework.data.elasticsearch.core.TotalHitsRelation;
    import org.springframework.data.elasticsearch.core.query.Query;
    import tech.yummy.devops.hotwheel.business.pojo.ao.system.OperateLogAO;
    import tech.yummy.devops.hotwheel.business.pojo.ao.system.OperateLogSearchAO;
    import tech.yummy.devops.hotwheel.business.pojo.vo.system.OperateLogVO;
    
    import java.time.LocalDateTime;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Random;
    
    /**
     * 操作日志 服务层处理 - ES 实现
     *
     * @author ruoyi
     */
    public class SysOperLogEsServiceImplTest {
    
        @Tested
        private SysOperLogEsServiceImpl sysOperLogEsService;
    
        @Injectable
        private ElasticsearchRestTemplate elasticsearchTemplate;
    
        @Test
        void insertOperlog() {
    
            OperateLogAO operateLogAO = DataProvider.anyObject(OperateLogAO.class);
            sysOperLogEsService.insertOperlog(operateLogAO);
    
            new Verifications() {
                {
                    elasticsearchTemplate.save(operateLogAO);
                    times = 1;
                }
            };
        }
    
        @Test
        void selectOperLogList() {
            // 录制返回
            new Expectations(){{
                elasticsearchTemplate.search((NativeSearchQuery)any, OperateLogAO.class);
                result = geneData();
            }};
    
            OperateLogSearchAO searchAO = DataProvider.anyObject(OperateLogSearchAO.class);
    
            List<OperateLogVO> list = sysOperLogEsService.selectOperLogList(searchAO);
            Assertions.assertThat(list)
                    .isNotNull();
        }
    
        public static SearchHits<OperateLogAO> geneData(){
    
            // 随机返回0~3条
            int max = 3;
            int size = new Random().nextInt(max);
            List<SearchHit>> list = new ArrayList&lt;>();
            for(int i=0;i<size xss=removed> hit = new SearchHit&lt;>(
                        DataProvider.anyObject(String.class),
                        0F,
                        null,
                        null,
                        ao
                );
                list.add(hit);
            }
    
            SearchHits<OperateLogAO> hits = new SearchHits<OperateLogAO>() {
                @Override
                public Aggregations getAggregations() {
                    return new Aggregations(new ArrayList&lt;>());
                }
    
                @Override
                public float getMaxScore() {
                    return 0;
                }
    
                @Override
                public SearchHit<OperateLogAO> getSearchHit(int index) {
                    return getSearchHits().get(index);
                }
    
                @Override
                public List<SearchHit>> getSearchHits() {
                    return list;
                }
    
                @Override
                public long getTotalHits() {
                    return list.size();
                }
    
                @Override
                public TotalHitsRelation getTotalHitsRelation() {
                    return TotalHitsRelation.EQUAL_TO;
                }
            };
    
            return hits;
        }
    
    }

    MQ测试基类

    kafka-junit
    spring-kafka-test
    使用 spring-kafka-test

    @EmbeddedKafka 注解会帮我们实例化一个EmbeddedKafkaBroker对象放到spring容器中
    通过参数spring.kafka.bootstrap-servers确定要连接的kafka broker列表,初始化kafka配置时主要是拿到这个连接地址,即启动的broker的连接地址,该地址可用EmbeddedKafkaBroker对象的getBrokersAsString()方法获取。其他具体的配置可在kafka配置类中视项目情况指定。
    依赖引入
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka-test</artifactId>
        <scope>test</scope>
    </dependency>

    配置类

    @ActiveProfiles("junit-test")
    @Configuration
    public class KafkaMockConfig<K> {
        @Autowired
        // 获取broker对象,主要是通过该对象获取broker连接地址用于生产者、消费者配置。该配置是使用 @EmbeddedKafka 实例化并放到容器中的。
        EmbeddedKafkaBroker embeddedKafkaBroker;
    
        // 生产者配置 可以识别容器中的消费者,所以需要将测试的消费者也放到容器中
        @Bean
        public KafkaListenerAnnotationBeanPostProcessor kafkaListenerAnnotationProcessor() {
            return new KafkaListenerAnnotationBeanPostProcessor();
        }
    
        @Bean(name = KafkaListenerConfigUtils.KAFKA_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME)
        public KafkaListenerEndpointRegistry defaultKafkaListenerEndpointRegistry() {
            return new KafkaListenerEndpointRegistry();
        }
    
        @Bean
        ConcurrentKafkaListenerContainerFactory<K> kafkaListenerContainerFactory() {
            ConcurrentKafkaListenerContainerFactory<K> factory = new ConcurrentKafkaListenerContainerFactory&lt;>();
            factory.setConsumerFactory(consumerFactory());
            return factory;
        }
    
        @Bean
        public ConsumerFactory<K> consumerFactory() {
            Map<String> props = new HashMap(8);
            props.put("bootstrap.servers", embeddedKafkaBroker.getBrokersAsString());
            props.put("group.id", "demoGroup");
            props.put("enable.auto.commit", "true");
            props.put("auto.commit.interval.ms", "10");
            props.put("session.timeout.ms", "60000");
            props.put("key.deserializer", StringDeserializer.class);
            props.put("value.deserializer", JsonDeserializer.class);
            props.put("auto.offset.reset", "earliest");
            JsonDeserializer deserializer = new JsonDeserializer<V>();
            // 添加授信包,防止消费消息反序列化时报错:包不被信任
            deserializer.addTrustedPackages("*");
            return new DefaultKafkaConsumerFactory&lt;>(props, new StringDeserializer(), deserializer);
        }
    
        @Bean
        public KafkaTemplate<K> kafkaTemplate() {
            Map<String> props = new HashMap(8);
            props.put("bootstrap.servers", embeddedKafkaBroker.getBrokersAsString());
            props.put("retries", 0);
            props.put("batch.size", "16384");
            props.put("linger.ms", 1);
            props.put("buffer.memory", "33554432");
            props.put("key.serializer", StringSerializer.class);
            props.put("value.serializer", JsonSerializer.class);
            return new KafkaTemplate<K>(new DefaultKafkaProducerFactory&lt;>(props));
        }
    
        // 消费者、生产者及其依赖都需要在容器中
        @Bean
        public DemoProducer demoProducer() {
            return new DemoProducer();
        }
    
        @Bean
        public CmdbMemberChangeConsumer cmdbMemberChangeConsumer() {
            return new CmdbMemberChangeConsumer();
        }
    
        @Bean
        public ICmdbUserRoleService cmdbUserRoleService() {
            return new CmdbUserRoleServiceImpl();
        }
    
        @Bean
        public CmdbUserRoleMapper cmdbUserRoleMapper() throws IOException {
            CmdbUserRoleMapper cmdbUserRoleMapper = BaseDaoTestUtil.getMapper("mapper/cmdb/CmdbUserRoleMapper.xml", CmdbUserRoleMapper.class);
            SqlSession sqlSession = BaseDaoTestUtil.getSqlSession();
            Connection connection = sqlSession.getConnection();
            // 创建 ScriptRunner,读取 SQL 脚本并执行
            ScriptRunner runner = new ScriptRunner(connection);
            runner.setErrorLogWriter(null);
            runner.setLogWriter(null);
            // 初始化SQL脚本
            runner.runScript(Resources.getResourceAsReader("sql/CmdbUserRole.sql"));
            return cmdbUserRoleMapper;
        }
    
        @Bean
        public RedisCache redisCache() {
            return RedisServerHolder.getRedisCache();
        }
    
        @Bean(name = "devopsHotwheelRedisTemplate")
        public RedisTemplate redisTemplate() {
            return RedisServerHolder.getRedisTemplate();
        }
    }

    辅助类

    
    public class DemoProducer {
        @Autowired
        KafkaTemplate kafkaTemplate;
    
        public void sendCmdServiceChangeMessage() {
            ServiceEventBO eventBO = new ServiceEventBO();
            eventBO.setServiceName("hello-spring");
            kafkaTemplate.send("devops-cmdb-service-event", eventBO);
        }
    }

    测试类

    // SpringExtension是JUnit多个可拓展API的一个实现,提供了对现存Spring TestContext Framework的支持
    @ExtendWith(SpringExtension.class) 
    @EmbeddedKafka(topics = {"demoTopic"}) // 启动broker
    @ImportAutoConfiguration(classes = KafkaMockConfig.class) // 只加载该配置类中的bean
    @Slf4j
    public class DemoMqTest {
        @Autowired
        DemoProducer demoProducer;
    
        @Test
        void cmdbMemberChangeConsumerTest() throws InterruptedException {
            demoProducer.sendCmdServiceChangeMessage();
            /**
             * 休眠1秒,防止消息还没消费broker就shutdown了
             * 这里仅为了测试,正常不应该这样去使用,会影响单测耗时
             * 可改写EmbeddedKafkaCondition.afterAll方法,等待消息被消费后再销毁broker
             */
            Thread.sleep(1000 * 1);
        }
    }

    点赞(1) 打赏

    Comment list 共有 0 条评论

    暂无评论
    立即
    投稿
    发表
    评论
    返回
    顶部