单元测试的两种方案

原则

单元测试FIRST原则如下

  • 快速(fast):单元测试应该是快速运行的,否则将耗费掉很多开发/部署时间。
  • 隔离(isolated):不同的测试用例之间是隔离的。一个测试不会依赖另一个测试。
  • 可重复(repeatable):单元测试是可重复运行的,且在重复运行时,单元测试总能给出相同的结果(系统环境无关)。
  • 自我验证(self-validating):单元测试可以验证它们的结果,当它们全部通过时,给出一个简单的“OK”报告,当它们失败时,需要输出描述简明的细节。
  • 及时(timely):程序员在代码上线前,应该及时地编写它们,以防止bug。

Junit+Mockito+H2数据库

 

整体思路是二方服务的API、配置型数据均采用mock方式,结合使用H2内存数据库来初始化数据达到单元测试的目的。

二方服务的API采用mock方式

为了减小单元测试执行时间,需要去除二方服务api的加载并以mock对象替代,api mock的方式一般是在Spring启动时进行mock注入,即mock对象替换真实的对象。这里可使用Spring自带的 @TestExecutionListeners 注解来进行统一mock二方服务api,执行步骤如如下:

  1. 创建TestContextManager
  2. 依次执行各个 AbstractTestExecutionListener 实例的 beforeTestClass 方法
  3. 依次执行各个 AbstractTestExecutionListener 实例的 prepareTestInstance 方法
  4. Spring加载并解析XML,在此过程中执行BeanFactoryPostProcessor
  5. 执行一个测试方法时,依次执行各个 AbstractTestExecutionListener 实例的 beforeTestMethod 方法
  6. 执行测试方法
  7. 依次执行各个 AbstractTestExecutionListener 实例的 afterTestMethod 方法 

上述方式可以达到按@Mock注解标记的对象能统一进行对象mock,不加载外部环境,做到环境隔离、单元测试运行速度快等特点。详细代码如下:

public class MockitoBeansPostProcessor implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        Map<Class<?>, MockitoBeansTestExecutionListener.MockBeanWrapper> allMockBeans = MockitoBeansTestExecutionListener.resolvedAllMockBeans();
        for (Map.Entry<Class<?>, MockitoBeansTestExecutionListener.MockBeanWrapper> mockBeanWrapperEntry : allMockBeans.entrySet()) {
            beanFactory.registerResolvableDependency(mockBeanWrapperEntry.getKey(), mockBeanWrapperEntry.getValue().getMockObject());
            beanFactory.registerSingleton(mockBeanWrapperEntry.getValue().getBeanName(), mockBeanWrapperEntry.getValue().getMockObject());
        }
    }

}	

public class MockitoBeansTestExecutionListener extends DependencyInjectionTestExecutionListener {

    private static  Map<Class<?>, MockBeanWrapper> mockBeans = new ConcurrentHashMap<>();
    private static  Map<Class<?>, List<Field>> injectMockBeans = new ConcurrentHashMap<>();
    private static boolean hasInitialized = false;

    public static Map<Class<?>, MockBeanWrapper> resolvedAllMockBeans() {
        Assert.isTrue(hasInitialized);
        return Collections.unmodifiableMap(mockBeans);
    }

    @Override
    public void beforeTestClass(TestContext testContext) throws Exception {
        Field[] declaredFields = testContext.getTestClass().getDeclaredFields();
        // 拿到父类的Mock方法
        Field[] superClassFields = testContext.getTestClass().getSuperclass().getDeclaredFields();
        List<Field> mockFields = Lists.newArrayList(declaredFields);
        if (superClassFields.length > 0) {
            mockFields.addAll(Lists.newArrayList(superClassFields));
        }
        //将需要mock的对象创建出来
        for (Field field : mockFields) {
            Mock mockAnnon = field.getAnnotation(Mock.class);
            if (mockAnnon != null) {
                field.setAccessible(true);
                MockBeanWrapper wrapper = new MockBeanWrapper();
                Class<?> type = field.getType();
                wrapper.setMockObject(Mockito.mock(type));
                wrapper.setBeanType(type);
                wrapper.setBeanName(StringUtils.isEmpty(mockAnnon.value()) ? field.getName() : mockAnnon.value());
                mockBeans.putIfAbsent(wrapper.getBeanType(), wrapper);
                injectMockBeans.compute(testContext.getTestClass(), (targetClass, waitInjectFields) -> {
                    if (waitInjectFields == null) {
                        waitInjectFields = new ArrayList<>();
                    }
                    waitInjectFields.add(field);
                    return waitInjectFields;
                });
            }
        }
        hasInitialized = true;
    }

    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {
        Object testInstance = testContext.getTestInstance();
        List<Field> fields = injectMockBeans.get(testContext.getTestClass());
        if (fields != null) {
            for (Field field : fields) {
                field.setAccessible(true);
                field.set(testInstance, mockBeans.get(field.getType()).getMockObject());
            }
        }
    }

    @Data
    public class MockBeanWrapper {
        private String beanName;
        private Class<?> beanType;
        private Object mockObject;
    }
}


// 单元测试基类
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"classpath:promotion/config/application-unittest.xml"})
@TestExecutionListeners({MockitoBeansTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        SqlScriptsTestExecutionListener.class})
public abstract class AbstractTest {
    @Mock
    protected LogisticService logisticService;
    
    @Mock
    protected ItemBatchSpecService itemBatchSpecService;
    
    //......
}

H2内存数据库进行数据初始化

H2数据库是嵌入式的内存型数据库,其语法与MySQL语法非常接近,非常适用于单元测试数据准备场景。所以需要达到属于本应用负责的数据直接依赖H2数据库存储,单个用例使用Spring@sql注解单独准备数据,即应用负责的方法不进行mock,从上层一直到数据库真实调用。详细配置如下:

<jdbc:embedded-database id="dataSource" type="H2">
        <jdbc:script location="classpath:common/common_schema.sql"/>
    </jdbc:embedded-database>

    <bean id="h2SqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="configLocation" value="classpath:common/mybatis-config.xml"/>
        <property name="mapperLocations">
            <list>
                <value>classpath:mapper/brule/Promotion*.xml</value>
            </list>
        </property>
    </bean>
    
    <bean id="mapperScannerConfigurer" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="com.yt.smc.dal.flashbuy.mapper,com.yt.smc.dal.brule.mapper"/>
        <property name="sqlSessionFactoryBeanName" value="h2SqlSessionFactory"/>
    </bean>

单元测试示例

单元测试类只需要继承单元测试基类AbstractTest即可,示例代码如下:

public class NoBatchPlaceOrderTest extends AbstractTest {

    @Autowired
    private TradeService tradeService;

    @Sql("classpath:promotion/db/trade/placeorder/nobatch_place_order_01.sql")
    @Test
    public void testplaceOrderFinishTest1() {
        Mockito.when(itemPriceService.skuPrice(any())).thenReturn(getNoBatchSkuPriceMap());

        SmcResultData<List<CouponOwnerShareDTO>> smcResultData = new SmcResultData<>();
        smcResultData.setData(Lists.newArrayList());
        Mockito.when(couponTradeService.shareCouponOwner(any(), any())).thenReturn(smcResultData);

        SmcResultData<PaceOrderFinishReDTO> paceOrderFinishResult = new SmcResultData<>();
        PaceOrderFinishReDTO paceOrderFinishReDTO = new PaceOrderFinishReDTO();
        paceOrderFinishResult.setData(paceOrderFinishReDTO);
        Mockito.when(promotionPlaceExecuteService.placeOrderFinishPromotion(any(), any())).thenReturn(paceOrderFinishResult);

        Mockito.when(itemSqueryAdapter.listItemByIds(Mockito.any())).thenReturn(getItemDetailList());

        PlaceOrderRequestDTO requestDTO = buildPlaceOrderRequest1();
        PlaceOrderResponseDTO responseDTO = tradeService.placeOrderFinish(requestDTO);
        System.out.println(JSON.toJSONString(responseDTO));
        Assert.assertNotNull(responseDTO);
        Assert.assertEquals(1, responseDTO.getPromotionPlaceOrderDTOS().size());
        Assert.assertEquals(16900, responseDTO.getPromotionPlaceOrderDTOS().get(0).getOrderPrice().longValue());
        Assert.assertEquals(1, responseDTO.getPromotionPlaceOrderDTOS().get(0).getPromotionPlaceActivityDTOs().size());
        Assert.assertEquals(3000, responseDTO.getPromotionPlaceOrderDTOS().get(0).getPromotionPlaceActivityDTOs().get(0).getActivityCreatePrice().longValue());
    }	

Spock+Mockito+H2数据库

采用Spock的思想基本同Juit思想一样,区别点在使用groovy语言进行写单元测试。使用groovy进行单元测试基于行为驱动开发(BDD)的思想,单元测试过程中需要将用例对应为given、when和then,与Junit相比这种方式更加贴近业务表达。首先引入依赖如下(注意要选择相匹配的版本,版本不一致可能会引起单元测试失败):

 <!--Spock测试框架-->
            <dependency>
                <groupId>org.spockframework</groupId>
                <artifactId>spock-spring</artifactId>
                <version>1.3-groovy-2.5</version>
                <scope>test</scope>
            </dependency>
            
            <dependency>
                <groupId>org.codehaus.groovy</groupId>
                <artifactId>groovy-all</artifactId>
                <version>2.4.4</version>
            </dependency>

再基于上述mock思路创建AbstractSpockTest基类,如下:

@ContextConfiguration(locations = "classpath:promotion/config/application-unittest.xml")
@TestExecutionListeners([
        MockitoBeansTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        SqlScriptsTestExecutionListener.class
])
abstract class AbstractSpockTest extends Specification {

    @Mock
    protected ItemBatchSpecService itemBatchSpecService;
    
    //......
}

单元测试示例如下:

class UserTaskServiceSpockTest extends AbstractSpockMockTest {

    @Autowired
    private UserTaskService userTaskService;

    @Sql("classpath:db/share/query_share_detail_info.sql")
    @Unroll
    def "被分享人扫分享码详情单元测试"() {
        given:
        Mockito.when(activityService.checkActivityById(any(), any())).thenReturn(getCheckReturnResult())
        Mockito.when(activityService.checkUser(any(), any(), any())).thenReturn(getCheckReturnResult())
        Mockito.when(iSnsUserServiceWrapper.getSnsUserById(any())).thenReturn(getSnsTaoBaoUserDTO())
        ShareDetailQueryRequest shareDetailQueryRequest = new ShareDetailQueryRequest(shareCode: shareCode, userId: userId)

        expect:
        ServiceResult<ScanShareResultVO> result = userTaskService.queryShareDetailInfo(shareDetailQueryRequest)
        result.getData().getSelf() == self
        result.getData().getSourceUserId() == sourceUserId

        where:
        shareCode | userId | self | sourceUserId
        "da0f4a99c41ff8cc3634830552239c8fb5503076ab" | 3L | false | 5L
        "da0f4a99c41ff8cc3634830552239c899e683076ab" | 4L | true | 5L
    }
}

总结

两种实现方式原理相同,从风格上讲"Spock+Mockito+H2数据库"这种方式代码量更少、更贴近业务表达,推荐!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bboyzqh

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值