0


单元测试优化实践总结

单元测试优化实践总结

原则

最小依赖原则

单个单元测试执行时,尽量只编写、使用、加载必要的组件或内容,对于本地单元测试无用的内容尽量不要在编写或运行阶段引入进来。
聚焦原则
单个单元测试方法的测试对象仅局限于被测试方法一层,对于被测试方法所依赖的方法(private方法除外)、对象、属性等全部要进行模拟处理。对于private方法建议是跟随此方法的调用方一起测试。

数据隔离原则

对于操作DB、Redis、MQ、消息通知等中间件或持久化工具的方法,所产生的数据要与实际环境(包含:开发、测试、预演、生产的各种环境)的数据进行隔离,不要对实际环境的数据产生持久化影响。
可以使用独立的或者模拟的DB、Redis、MQ、消息通知服务进行代替,若使用实际环境的服务要做好完全的数据隔离(防止单元测试与实际环境运行发生冲突,造成莫名其妙的问题)。

随机性原则

单元测试中使用到的测试数据,应尽量是变动的、随机的。

方法论

对于单元测试的目标对象,从是否依赖其他服务角度进行区分可以分为两大类,
第一类就是系统内部逻辑方法,无需依赖其他服务的测试对象,被测试对象所依赖的方法等全部在本服务内部实现,例如service中的方法等;
第二类就是需要依赖其他服务的测试对象,例如对其他服务接口的调用代理,包含但不限于httpClient、feign proxy、redis client、DB client等。在这里面,又根据封装程度可以分为自定义脚本与完全接口调用两个子类,自定义脚本就是例如myBatis一类的需要自行编写脚本的被测试对象,其中的脚本是测试的一部分或者说主要就是测试脚本功能是否正确,完全接口调用类就是已经有了完善的封装,任何或绝大部分功能的实现只需要调用官方或社区提供的客户端的对应方法即可。

参考用例

Feign、Redis、ES、MQ等代理类或客户端以(Feign代理为例):

按照Feign的规范和一般的编程习惯,会有一个Feign接口及相应的proxy代理类,Redis、ES、MQ则就是官方或社区提供的工具包,其符合一个特点:只要按照规范使用就几乎不会有问题。
Feigh接口:

@FeignClient(name="one_service", url="https://127.0.0.1:8080/one_service")publicinterfaceServiceClient{@ApiOperation(value="数据查询接口")@PostMapping("/query")DataResponse<List<String>>query(@RequestBodyArgument arg);}

Proxy代理类方法:

@CommponentpublicclassFeignProxy{@AutowiredprivateServiceClient serviceClient;publicList<String>query(String serialNo){Arguemnt arg =newArgument();
        arg.setSerialNo(serialNo);DataResponse<List<String>> result = serviceClient.query(arg);// 统一的对接口返回对象有效性检查方法ResponseDataUtils.check(result);return result.getData();}}

在这个场景中,Feign接口方法是给Feign框架使用的,其正确性由框架保证,只要我们按照规范进行编写就可以正常运行,故我们可以认为是安全且无需测试的,所以在Proxy代理类方法中,serviceClient使用一个Mock对象代替。responseDataUtils同样在此场景下不是被测试的焦点内容,也适用Mock对象代替。

如何验证此场景下的正确性

1.关注Mock对象的serviceClient的query方法的入参对象中的各字段的值是否是我们所期望的。
2.关注方法的返回值是否是我们期望的。

@RunWith(PowerMockRunner.class)publicclassFeignProxyTest{@InjectMocksprivateFeignProxy;@MockprivateServiceClient;@TestpublicvoidfeignQueryTest(){String serialNo ="SN"+newRandom().nextInt(999999999);String resultData ="RD"+newRandom().nextInt(999999999);Mockito.when(serviceClient.query(Mockito.any(ServiceClient.class)).thenAnswer(a ->{// 获取方法的入参Argument arg = a.getArgument(0, Argument.class);// 根据mockito工具的不同、版本的不同此方法亦不同// 验证方法的入参值是否为期望值Assert.assertEquals(serialNo, arg.getSerialNo());returnCollections.singleTonList(resultData);});List<String> result = proxy.query(serialNo);Assert.assertEquals(resultData, result.get(0));}}
方法有入参但无返回值

有些时候我们的方法是没有返回值的,也就无法通过返回值的验证来判定是否逻辑正确,但可以通过验证内部依赖的其他方法的入参是否符合预期来验证逻辑的正确性。

@ServicepublicclassService{@AutowiredprivateServiceA a;@AutowiredprivateServiceB b;publicvoidtargetMethod(String name){// other code......int aResult = a.init(name);Argument arg =newArgument();
        arg.setName(name);
        arg.setInt(aResult);// other code......
        b.check(arg);}}
如何验证此场景下的正确性

可以通过验证被测试方法内所有方法或最后一个方法的入参是否符合预期,来间接验证被测试方法的逻辑运行是否符合预期。

@RunWith(PowerMockRunner.class)publicclassServicceTest{@InjectMocksprivateService service;@MockprivateServiceA a;@MockprivateServiceB b;@TestpublicvoidtargetMethodTest(){String name ="name_"+newRandom().nextInt(999999999);int temp =newRandom().nextInt(9999);Mockito.when(a.init(Mockito.anyString()).thenAnswer(e ->{// 获取方法的入参String arg = e.getArgument(0, String.class);// 根据mockito工具的不同、版本的不同此方法名亦不同// 验证方法的入参值是否为期望值Assert.assertEquals(name, arg);// 因为ServiceA的check方法的返回值是个int,所以此处要返回一个int类型对象return temp;});// 以上检测可以简写为一下形式// Mockito.when(a.init(name)).thenReturn(temp);Mockito.when(b.check(Mockito.any(Argument.class)).thenAnswer(e ->{// 获取方法的入参Argument arg = e.getArgument(0, Argument.class);// 根据mockito工具的不同、版本的不同此方法名亦不同// 验证方法的入参值是否为期望值Assert.assertEquals(name, arg.getName());// 间接证明a.init()方法的调用是否符合我们的预期Assert.assertEquals(temp, arg.getInt());// 因为ServiceB的check方法没有返回值,所以此处返回个nullreturnnull;});
        
        service.targetMethod(name);}}
依赖了复杂的静态方法如何模拟(以Spring事件广播为例)

SpringBoot的事件广播可以使用注解或SpringContextUtil.getContext().publishEvent(event)方式进行广播。

@ComponentpublicclassService{publicbooleansendEvent(String name){EventContext eventContext =newEventContext();
        eventContext.setDate(newDate());
        eventContext.setName(name);Event event =newEvent();
        event.setContext(eventContext);SpringContextUtil.getContext().publishEvent(event);returntrue;}}
如何验证此场景下的正确性

SpringContextUtil.getContext().publishEvent(event)这行代码进行拆解可以拆解为:
ApplicationContext applicationContext = SpringContextUtil.getContext();
applicationContext.publishEvent(event);
可以使用PowerMockito提供的Mock静态方法的能力对SpringContextUtil.getContext()方法进行处理,使其返回一个Mock对象,再通过对这个Mock对象的publishEvent方法的入参进行验证,来间接验证我们的逻辑是否正确。

@RunWith(PowerMockRunner.class)@PrepareForTest({SPringContextUtil.class})publicclassEventSenderTest{@InjectMocksprivateEventSender sender;@MockprivateApplicationContext applicationContext;@Beforepublicvoidbefore(){PowerMockito.mockStatic(SpringContextUtil.class);PowerMockito.when(SpringContextUtil.getContext()).thenReturn(applicationContext);}@TestpublicvoidtestSendEvent(){String name ="name_"+newRandom().nextInt(999999);Mockito.doAnswer(a ->{Event arg = a.getArgument(0,Event.class);EventContext eventContext = arg.getContext();Assert.assertNotNull(eventContext.getDate());Assert.assertEquals(name, eventContext.getName());// applicationContext.publishEvent(event)方法无返回值,所以此处返回一个nullreturnnull;}).when(applicationContext).publishEvent(Mockito.any(Event.class));boolean result = sender.sendEvent(name);Assert.assertTrue(result);}}
逻辑分支的运行是否符合预期

代码逻辑中免不了会存在if…else…的分支,例如inserOrUpdate语义的场景时,就需要测试出来是否调用了正确的方法。

@ServicepublicclassService{@AutowiredprivateTableMapper tableMapper;publicbooleaninsertOrUpdate(String name,String info){TableModel model =newTableModel();
        model.setName(name);
        model.setInfo(info);if(tableMapper.checkName(name)){return1== tableMapper.update(model);}else{return1== tableMapper.insert(model);}}}
如何验证此场景下的正确性

可以使用Mockito.verify方法对Mock对象的方法调用次数与参数进行验证。

importstaticorg.junit.Assert.*;importstaticorg.mockito.Mockito.*;@RunWith(PowerMockRunner.class)publicclassServiceTest{@InjectMocksprivateService service;@MockprivateTableMapper tableMapper;@TestpublicvoidtestInsertOrUpdate(){String name ="name_"+newRandom().next(999999);String info ="info_"+newRandom().next(999999);// checkName方法返回true,所以接下来应该调用update方法,不应该调用insert方法when(tableMapper.checkName(name)).thenReturn(true);when(tableMapper.update(any(TableModel.class)).thenAnswer(a ->{TableModel arg = a.getArgument(0,TableModel.class));assertEquals(name, arg.getName());assertEquals(info, arg.getInfo());return1;});assertTrue(service.insertOrUpdate(name, info));// 验证tableMapper的update方法被调用了一次verify(tableMapper).update(any(tableModel.class));// 验证tableMapper的insert方法被调用了零次verify(tableMapper,never()).insert(any(tableModel.class));}}
异常如何验证

正常业务逻辑中难免要抛出异常,符合预期的异常也是测试通过的表现之一。

@ServicepublicclassService{@AutowiredprivateTableMapper tableMapper;publicvoidcount(String name){int count = tableMapper.count(name);if(count <=10){thrownewRunTimeException("数量太少了,情况不正常");}elseif(count >=30){thrownewRunTimeException("数量太多了,情况不正常");}}}
如何验证此场景下的正确性

可以使用Mockito提供的Rule注解+ExpectedException对象的组合,设定方法抛出的预期异常。单元测试运行时捕获到了预期异常,就证明逻辑符合预期。

importstaticorg.junit.Assert.*;importstaticorg.mockito.Mockito.*;@RunWith(PowerMockRunner.class)publicclassServiceTest{@InjectMocksprivateService service;@MockprivateTableMapper tableMapper;@Rule// 注意:此处的方法可见性必须为publicpublicExpectedException exception =ExpectedException.none();@TestpublicvoidtestInsertOrUpdate(){String name ="name_"+newRandom().next(999999);when(tableMapper.count(name)).thenReturn(100);// 设定预期异常的类型
        expection.expect(RuntimeException.class);// 设定预期异常的异常信息,方便区分有多个相同异常类型时具体时那个地方抛出了异常// 期望信息是含有匹配:‘数量太多了,情况不正常’和‘数量太多了’只能匹配到12行抛出的异常,但‘情况不正常’可以匹配10行和12行抛出的异常
        expection.expectMessage("数量太多了,情况不正常");
        
        service.count(name);}}
如何对含有null参数的方法调用进行mock

某些时候调用底层的公共方法时,部分参数会直接赋予null。

@ComponentpublicclassProcessor{publicvoidprocess(String name,String info){// cods.....}}@ServicepublicclassService{@AutowiredprivateProcessor processor;publicvoidbuild(String name){// other codes......
        processor.process(name,null);// other codes......return"name:"+ name;}}

可以使用Mockito提供nullable方法

@RunWith(PowerMockRunner.class)publicclassServiceTest{@InjectMocksprivateService service;@MockprivateProcessor processor;@TestpublicvoidtestInsertOrUpdate(){String name ="name_"+newRandom().next(999999);Mockito.doNothing().when(processor).process(Mockito.anyString(),Mockito.nullable(String.class));String result = service.build(name);Assert.assertEquals("name:"+ name, result);}}
依赖配置中心时如何解决

当前项目实践中,为了配置的灵活性,通常会引入Apollo、Nacos等配置中心进行配置的统一管理。

@ServicepublicclassService{@Value("${flag}")privateString flag;publicbooleanisTemp(){return flag.equals("temp");}}
如何验证此场景下的正确性

可以使用PowerMockito提供的设置对象属性值的方法,在单元测试中为配置项设置。

@RunWith(PowerMockRunner.class)publicclassServiceTest{@InjectMocksprivateService service;@TestpublicvoidtestIsTemp_false(){Whitebox.setInternalState(service,"flag","noTemp");Assert.assertFalse(service.isTemp());}@TestpublicvoidtestIsTemp_true(){Whitebox.setInternalState(service,"flag","temp");Assert.assertTrue(service.isTemp());}}

在SpringRunner环境下也可以通过ReflectionTestUtils 提供的设置对象属性值方法,在单测启动时动态修改配置项

@RunWith(SpringRunner.class)publicclassServiceTest{@autoWiredprivateService service;@beforepublicvoidinitAPolloParam(){ReflectionTestUtils.setField("service","apolloParam",Boolean.True);}}
抽象类中的方法如何测试

抽象类中往往会放一些统一的逻辑内容,同时还会定义一些抽象方法由子类实现。同时抽象类又不能被直接实例化。

publicabstractclassAbstractProcess{@AutowiredprivateTableMapper tableMapper;protectedabstractbooleandoProcess(TabelModel model);protectedabstractvoidclean(int value);publicbooleanprocess(String name,int value){// other codes......TableModel model = tableMapper.select(name);boolean processResult =doProcess(model);if(!processResult){clean(value);}return processResult;}}
如何验证此场景下的正确性

一种方法是在单元测试类中以私有内部类的方式,做一个继承了抽象类的最简单的实现类出来,即所有的抽象方法都是空白实现,仅仅是给单元测试一个被测试对象。但这种方法有两点问题,第一个是抽象方法过多时,内部类会过于冗长;第二个是像上方示例中的process方法一样,其返回值依赖一个或多个抽象方法的返回值,此时对于返回值的校验也会变得麻烦。
推荐将使用Mockito的spy方法对抽象类进行包装,构造一个被测试对象出来。

@RunWith(PowerMockRunner.class)publicclassAbstractProcessTest{@InjectMocksprivateAbstractProcess process =Mockito.spy(AbstractProcess.class);@MockprivateTableMapper tableMapper;@TestpublicvoidtestProcess(){String name ="name_"+newRandom().next(999999999);TabelModel model =newTableModel();int value =newRandom().next(999);
    model.setName(name);Mockito.when(tableMapper.select(name)).thenReturn(model);Mockito.doReturn(true).when(process).doPrcess(model);Mockito.doNothing().when(process).clean(Mockito.anyInt());boolean result = process.process(name, value);Assert.assertTrue(result);}
资源抢占类型场景如何测试(以Redis分布式锁为例)

为保证系统的稳定性和数据的安全性,有些场景下会通过加锁等方式进行控制。

@ComponentpublicclassDistributeLockUitl{@AutowiredprivateRedissonClient redissonClient;publicbooleantryLock(String key ,long time,TimeUnit unit){RLock lock = redissonClient.getLock(key);try{return lock.tryLock(time, unit);}catch(InterruptedException e){
            log.error("获取分布式锁中断失败 key:[{}]", key);returnfalse;}}}
如何验证此场景下的正确性

设置一个模拟标示:是否已加锁。通过多次调用方法并验证的形式,验证是否加锁成功。

@RunWith(PowerMockRunner.class)publicvoidDistributeLockUtil{@InjectMocksprivateDistributeLockUitl lockUtil;@MockprivateRedissonClient redissonClient;@TestpublicvoidtestTryLock(){boolean locked =false;String lockName ="LN_"+newRandom().nextInt(999999999);RLock rlock =newRedissonLock(null, lockName);Mockito.when(redisson.getLock(lockName)).thenReturn(rlock);Mockito.when(rlock.tryLock(Mockito.anyLong(),Mockito.any(TimeUnit.class)).thenAnswer(a ->{String key = a.getArgument(0,String.class);// key一致则只能一次返回trueif(key.equals(lockName){boolean lockable =!locked;if(!locked){
                    locked =!locked;}return lockable;}else{// key不一致则均返回truereturntrue;}});// 第一次调用获取锁应成功Assert.assertTrue(lockUtil.tryLock(lockName,5L,TimeUnit.SECONDS));// 第二次调用获取锁应失败Assert.assertFalse(lockUtil.tryLock(lockName,5L,TimeUnit.SECONDS));// 保持关键参数不变,仅变换非必要参数调用方法也应返回失败Assert.assertFalse(lockUtil.tryLock(lockName,50L,TimeUnit.MINUTES));// 变更关键参数应返回trueAssert.assertTrue(lockUtil.tryLock("key_"+ lockName,5L,TimeUnit.SECONDS));Mockito.verify(rlock,Mockito.times(4)).getLock(Mockito.anyString());Mockito.verify(rlock,Mockito.times(4)).tryLock(Mockito.anyLong(),Mockito.any(TimeUnit.class));}}
Mybatis的动态SQL脚本如何测试

Mybatis的动态SQL脚本测试必须依赖于mybatis框架进行解析,但仅仅为了使用mybatis框架而启动整个Spring容器成本就太高了;同时生成出来的SQL脚本又需要执行DB进行执行;短时间大批量的请求实际环境的数据库建立连接对数据库服务也会造成不小的冲击。
寻寻觅觅了很久没有找到好用的轮子,就自己造一个出来。

准备工作

jar包:使用jar包可以忽略后续的1、2、3三个步骤,直接从步骤4开始操作。

<groupId>com.aihuishou</groupId><artifactId>mybatis-unit-test</artifactId><version>1.0-SNAPSHOT</version>

1.引入H2数据库,作为脚本执行的容器。这就放弃了少部分的SQL方言语法的支持。
2.准备三个文件在resources文件下:
a.db/schema-create.sql:数据库表表的创建脚本。
b.db/schema-data.sql:数据库中必要数据的插入脚本。
c.db/schema-drop.sql:数据库表清理脚本。
3.引入以下抽象工具类:

importorg.apache.ibatis.io.Resources;importorg.apache.ibatis.session.SqlSession;importorg.apache.ibatis.session.SqlSessionFactory;importorg.h2.jdbcx.JdbcDataSource;importorg.mybatis.spring.SqlSessionFactoryBean;importorg.springframework.core.io.support.PathMatchingResourcePatternResolver;importorg.springframework.jdbc.datasource.init.ScriptUtils;importjavax.sql.DataSource;importjava.io.LineNumberReader;importjava.sql.Connection;importjava.sql.PreparedStatement;importjava.util.ArrayList;importjava.util.List;publicclassAbstractMapperTestBase{privatestaticfinalDataSource dataSource;privatefinalString path;publicAbstractMapperTestBase(String path){this.path = path;}static{JdbcDataSource jdbcDataSource =newJdbcDataSource();
        jdbcDataSource.setUrl("jdbc:h2:mem:opt_trade;MODE=MySQL");
        jdbcDataSource.setUser("sa");
        dataSource = jdbcDataSource;}protected<T>TgetMapper(Class<T> clazz){try{runScript("db/schema-create.sql");runScript("db/schema-data.sql");SqlSessionFactoryBean factoryBean =newSqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            factoryBean.setMapperLocations(newPathMatchingResourcePatternResolver().getResources(path));SqlSessionFactory factory = factoryBean.getObject();SqlSession sqlSession = factory.openSession();return sqlSession.getMapper(clazz);}catch(Exception e){
            e.printStackTrace();returnnull;}}protectedvoidcleanDB(){try{runScript("db/schema-drop.sql");}catch(Exception e){
            e.printStackTrace();}}privatevoidrunScript(String filePathAndName)throwsException{Connection connection = dataSource.getConnection();List<String> scripts =newArrayList<>();String creates =ScriptUtils.readScript(newLineNumberReader(Resources.getResourceAsReader(filePathAndName)),"--",";");ScriptUtils.splitSqlScript(null, creates,";","--","/*","*/", scripts);for(int i =0; i < scripts.size(); i++){PreparedStatement preparedStatement = connection.prepareStatement(scripts.get(i));
            preparedStatement.execute();
            preparedStatement.close();}}}

4.继承此抽象类实现一个子类,在子类的无参构造方法中调用抽象类的构造方法时,提供Mybatis的mapper配置文件的路径:

importcom.aihuishou.opt.trade.util.AbstractMapperTestBase;/**
 * Mybatis Mapper测试的辅助类
 */publicclassMapperTestBaseextendsAbstractMapperTestBase{publicMapperTestBase(){// 替换成项目的实际路径super("classpath*:aaa/bbb/*.xml");}}
如何使用
publicclassTableMapperTestextendsMapperTestBase{privateTableMapper tableMapper;@Beforepublicvoidbefore(){// 必须从此处获得mapper接口的对线实例作为被测试对象
        tableMapper =getMapper(TableMapper.class);}@Testpublicvoidselect(){// 若要验证返回结果就需要保证此处的数据与schema-data.sql中写入到表中的数据一致String name ="Xiao Ming";// run test selectList<TableModel> results = tableMapper.selectByName(name);// 如果只是验证脚本语法的正确性,就无须理会返回结果,执行运行不报错就可以了// 若要进一步验证数据的正确性,就对方法的返回结果进行验证Assert.assertTrue(CollectionUtils.isEmptyList(results));TabelModel result = results.get(0);Assert.assertEquals(name, result.getName());}}

本文转载自: https://blog.csdn.net/u011924665/article/details/135134973
版权归原作者 大尾巴 所有, 如有侵权,请联系我们删除。

“单元测试优化实践总结”的评论:

还没有评论