单元测试优化实践总结

单元测试优化实践总结

原则

最小依赖原则

单个单元测试执行时,尽量只编写、使用、加载必要的组件或内容,对于本地单元测试无用的内容尽量不要在编写或运行阶段引入进来。
聚焦原则
单个单元测试方法的测试对象仅局限于被测试方法一层,对于被测试方法所依赖的方法(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")
public interface ServiceClient {
    @ApiOperation(value="数据查询接口")
    @PostMapping("/query")
    DataResponse<List<String>> query(@RequestBody Argument arg);
}

Proxy代理类方法:

@Commponent
public class FeignProxy {
    
    @Autowired
    private ServiceClient serviceClient;
    
    public List<String> query(String serialNo) {
        Arguemnt arg = new Argument();
        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)
public class FeignProxyTest {

    @InjectMocks
    private FeignProxy;
    
    @Mock
    private ServiceClient;

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

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

@Service
public class Service {
    
    @Autowired
    private ServiceA a;
    @Autowired
    private ServiceB b;
    
    public void targetMethod(String name) {
        // other code......
        int aResult = a.init(name);
        Argument arg = new Argument();
        arg.setName(name);
        arg.setInt(aResult);
        // other code......
        b.check(arg);
    }
}
如何验证此场景下的正确性

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

@RunWith(PowerMockRunner.class)
public class ServicceTest {

    @InjectMocks
    private Service service;
    
    @Mock
    private ServiceA a;
    @Mock
    private ServiceB b;

    @Test
    public void targetMethodTest() {
        String name = "name_" + new Random().nextInt(999999999);
        int temp = new Random().nextInt(9999);
        
        Mockito.when(a.init(Mockito.anyString()).thenAnswer(e -> {
            // 获取方法的入参
            String arg = e.getArgument(0String.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(0Argument.class);// 根据mockito工具的不同、版本的不同此方法名亦不同
            // 验证方法的入参值是否为期望值
            Assert.assertEquals(name, arg.getName());
            // 间接证明a.init()方法的调用是否符合我们的预期
            Assert.assertEquals(temp, arg.getInt());
            // 因为ServiceB的check方法没有返回值,所以此处返回个null
            return null;
        });
        
        service.targetMethod(name);
    }
}
依赖了复杂的静态方法如何模拟(以Spring事件广播为例)

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

@Component
public class Service {
    
    public boolean sendEvent(String name) {
        EventContext eventContext = new EventContext();
        eventContext.setDate(new Date());
        eventContext.setName(name);
        Event event = new Event();
        event.setContext(eventContext);
        SpringContextUtil.getContext().publishEvent(event);
        return true;
    }
}
如何验证此场景下的正确性

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

@RunWith(PowerMockRunner.class)
@PrepareForTest({SPringContextUtil.class})
public class EventSenderTest {

    @InjectMocks
    private EventSender sender;
    @Mock
    private ApplicationContext applicationContext;
    
    @Before
    public void before() {
        PowerMockito.mockStatic(SpringContextUtil.class);
        PowerMockito.when(SpringContextUtil.getContext()).thenReturn(applicationContext);
    }
    
    @Test
    public void testSendEvent() {
        String name = "name_" + new Random().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)方法无返回值,所以此处返回一个null
            return null;
        }).when(applicationContext).publishEvent(Mockito.any(Event.class));
        
        boolean result = sender.sendEvent(name);
        Assert.assertTrue(result);
    }
}
逻辑分支的运行是否符合预期

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

@Service
public class Service {
    
    @Autowired
    private TableMapper tableMapper;
    
    public boolean insertOrUpdate(String name, String info) {
        TableModel model = new TableModel();
        model.setName(name);
        model.setInfo(info);
        if (tableMapper.checkName(name)) {
            return 1 == tableMapper.update(model);
        } else {
            return 1 == tableMapper.insert(model);
        }
    }
}
如何验证此场景下的正确性

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

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

@RunWith(PowerMockRunner.class)
public class ServiceTest {
    
    @InjectMocks
    private Service service;
    
    @Mock
    private TableMapper tableMapper;
    
    @Test
    public void testInsertOrUpdate() {
        String name = "name_" + new Random().next(999999);
        String info = "info_" + new Random().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());
            return 1;
        });
        
        assertTrue(service.insertOrUpdate(name, info));
        
        // 验证tableMapper的update方法被调用了一次
        verify(tableMapper).update(any(tableModel.class));
        // 验证tableMapper的insert方法被调用了零次
        verify(tableMapper, never()).insert(any(tableModel.class));
    }
}
异常如何验证

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

@Service
public class Service {
    
    @Autowired
    private TableMapper tableMapper;
    
    public void count(String name) {
        int count = tableMapper.count(name);
        if (count <= 10) {
            throw new RunTimeException("数量太少了,情况不正常");
        } else if (count >= 30) {
            throw new RunTimeException("数量太多了,情况不正常");
        }
    }
}
如何验证此场景下的正确性

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

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

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

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

@Component
public class Processor {

    public void process(String name, String info) {
        // cods.....
    }

}



@Service
public class Service {
    
    @Autowired
    private Processor processor;
    
    public void build(String name) {
        // other codes......
        processor.process(name, null);
        // other codes......
        return "name:" + name;
    }
}

可以使用Mockito提供nullable方法

@RunWith(PowerMockRunner.class)
public class ServiceTest {
    
    @InjectMocks
    private Service service;
    
    @Mock
    private Processor processor;
    
    @Test
    public void testInsertOrUpdate() {
        String name = "name_" + new Random().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等配置中心进行配置的统一管理。

@Service
public class Service {
    
    @Value("${flag}")
    private String flag;
    
    public boolean isTemp() {
        return flag.equals("temp");
    }
}
如何验证此场景下的正确性

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

@RunWith(PowerMockRunner.class)
public class ServiceTest {

    @InjectMocks
    private Service service;
    
    @Test
    public void testIsTemp_false() {
        Whitebox.setInternalState(service, "flag", "noTemp");
        Assert.assertFalse(service.isTemp());
    }

    @Test
    public void testIsTemp_true() {
        Whitebox.setInternalState(service, "flag", "temp");
        Assert.assertTrue(service.isTemp());
    }
}

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

@RunWith(SpringRunner.class)
public class ServiceTest {

    @autoWired
    private Service service;
    
    @before
    public void initAPolloParam(){
      ReflectionTestUtils.setField("service","apolloParam",Boolean.True);
    }
}
抽象类中的方法如何测试

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

public abstract class AbstractProcess {

    @Autowired
    private TableMapper tableMapper;

    protected abstract boolean doProcess(TabelModel model);
    
    protected abstract void clean(int value);
    
    public boolean process(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)
public class AbstractProcessTest {
    
    @InjectMocks
    private AbstractProcess process = Mockito.spy(AbstractProcess.class);
    
    @Mock
    private TableMapper tableMapper;
    
    @Test
    public void testProcess() {
    String name = "name_" + new Random().next(999999999);
    TabelModel model = new TableModel();
    int value = new Random().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分布式锁为例)

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

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

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

@RunWith(PowerMockRunner.class)
public void DistributeLockUtil {
    
    @InjectMocks
    private DistributeLockUitl lockUtil;
    
    @Mock
    private RedissonClient redissonClient;
    
    @Test
    public void testTryLock() {
        boolean locked = false;
        String lockName = "LN_" + new Random().nextInt(999999999);
        RLock rlock = new RedissonLock(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一致则只能一次返回true
            if (key.equals(lockName) {
                boolean lockable = !locked;
                if (!locked) {
                    locked = !locked;
                }
                return lockable;
            } else {
                // key不一致则均返回true
                return true;
            }
        });
        
        // 第一次调用获取锁应成功
        Assert.assertTrue(lockUtil.tryLock(lockName, 5L, TimeUnit.SECONDS));
        // 第二次调用获取锁应失败
        Assert.assertFalse(lockUtil.tryLock(lockName, 5L, TimeUnit.SECONDS));
        // 保持关键参数不变,仅变换非必要参数调用方法也应返回失败
        Assert.assertFalse(lockUtil.tryLock(lockName, 50L, TimeUnit.MINUTES));
        // 变更关键参数应返回true
        Assert.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.引入以下抽象工具类:

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.init.ScriptUtils;

import javax.sql.DataSource;
import java.io.LineNumberReader;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;

public class AbstractMapperTestBase {

    private static final DataSource dataSource;
    private final String path;

    public AbstractMapperTestBase(String path) {
        this.path = path;
    }

    static {
        JdbcDataSource jdbcDataSource = new JdbcDataSource();
        jdbcDataSource.setUrl("jdbc:h2:mem:opt_trade;MODE=MySQL");
        jdbcDataSource.setUser("sa");
        dataSource = jdbcDataSource;
    }

    protected <T> T getMapper(Class<T> clazz) {
        try {
            runScript("db/schema-create.sql");
            runScript("db/schema-data.sql");
            SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
            factoryBean.setDataSource(dataSource);
            factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(path));
            SqlSessionFactory factory = factoryBean.getObject();
            SqlSession sqlSession = factory.openSession();
            return sqlSession.getMapper(clazz);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    protected void cleanDB() {
        try {
            runScript("db/schema-drop.sql");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void runScript(String filePathAndName) throws Exception {
        Connection connection = dataSource.getConnection();
        List<String> scripts = new ArrayList<>();
        String creates = ScriptUtils.readScript(new LineNumberReader(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配置文件的路径:

import com.aihuishou.opt.trade.util.AbstractMapperTestBase;

/**
 * Mybatis Mapper测试的辅助类
 */
public class MapperTestBase extends AbstractMapperTestBase {

    public MapperTestBase() {
        // 替换成项目的实际路径
        super("classpath*:aaa/bbb/*.xml");
    }
}
如何使用
public class TableMapperTest extends MapperTestBase {

    private TableMapper tableMapper;

    @Before
    public void before() {
        // 必须从此处获得mapper接口的对线实例作为被测试对象
        tableMapper = getMapper(TableMapper.class);
    }

    @Test
    public void select() {
        // 若要验证返回结果就需要保证此处的数据与schema-data.sql中写入到表中的数据一致
        String name = "Xiao Ming";
        // run test select
        List<TableModel> results = tableMapper.selectByName(name);
        
        // 如果只是验证脚本语法的正确性,就无须理会返回结果,执行运行不报错就可以了
        
        // 若要进一步验证数据的正确性,就对方法的返回结果进行验证
        Assert.assertTrue(CollectionUtils.isEmptyList(results));
        TabelModel result = results.get(0);
        Assert.assertEquals(name, result.getName());
    }
}

你可能感兴趣的:(单元测试,java,spring,boot,spring)