提高单元测试能力

单元测试痛点

1.开发人员在编写单元测试时,不免会与数据库进行数据交互,有查询,新增,删除,修改等操作,除查询之外的操作都会造成数据库脏数据的产生,会影响后续的测试,经年累月之后,不得不重新导入一份完整的数据以继续供测试时使用;

2.另外在开发分布式项目,也会经常与远程服务进行交互,当远程服务宕机时,则会影响测试进度;如果A服务功能需要依赖B服务,两个服务都处于开发中,如果A提前开发完,进行测试时,由于B服务还处于开发中则无法调用B服务;当远程服务都正常运行,在服务之间调用时,又会不免产生大量的脏数据,如果想处理脏数据,又需要去了解远程服务的业务,以及表结构,大大影响效率;

3.测试分支过多,需要编写多种测试用例,工作繁琐,往往工作量是编写接口的好几倍。

解决方式

业务代码

省了mapper层和controller层及一些实体类代码,有需要可以到代码参考下载项目进行参考

public interface CategoryService {
    void deleteById(Long id);

    Category findById(Long id);

    Long save(Category category);

    String getTypeDesc(Integer type);

}

@Service
@AllArgsConstructor
public class CategoryServiceImpl implements CategoryService {
    private final CategoryDao categoryDao;
    private final RemoteRpc remoteRpc;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteById(Long id) {
        categoryDao.deleteById(id);
    }

    @Override
    public Category findById(Long id) {
        return categoryDao.findById(id);
    }

    @Override
    public Long save(Category category) {
        categoryDao.save(category);
        return category.getId();
    }

    @Override
    public String getTypeDesc(Integer type) {
        if (type==1){
            return "ONE";
        }else if (type==2){
            return "TWO";
        }else if (type==3){
            return "THREE";
        }else {
            return "OTHER";
        }
    }
}

public interface SnacksService {
    String delete(Long id);
}

@Service
@Slf4j
@AllArgsConstructor
public class SnacksServiceImpl implements SnacksService {
    private final RemoteRpc remoteRpc;

    @Override
    public String delete(Long id) {
        log.info("删除成功,开始调用远程服务");
        return remoteRpc.invork(id.toString());
    }
}

@Component
@Slf4j
public class RemoteRpc {
    public String invork(String param){
        log.info("远程服务调用:{}",param);
        return "SUCCESS";
    }
}

内存数据库

不产生脏数据的方式就是在根源上杜绝与数据库产生交互,使用内存数据库是一个比较有效的途径,这边采用的是H2数据库,在单元测试启动时,会根据我们指定的建表语句和需要插入数据的sql文件来创建内存数据库,之后的数据交互遍便是与内存数据库进行。

  • 项目结构
image
  • pom
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.3.2</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

  • yml
spring:
  datasource:
    url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MySQL
    username:
    password:
    driver-class-name: org.h2.Driver
#    指定数据源
    data: classpath:data.sql
#    指定需要建表语句
    schema: classpath:schema.sql

mybatis-plus:
  configuration:
#    控制台打印sql 
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

  • 单元测试基类
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

//指定启动时加载的配置文件
@ActiveProfiles("test")
@SpringBootTest
public class BaseTest {
}

  • 业务测试类
import com.pdl.memory_database.domain.Category;
import com.pdl.memory_database.service.CategoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.Date;

public class CategoryTest extends BaseTest {
    @Autowired
    private CategoryService categoryService;

    /*
    实际删除的为内存数据库的数据,并不会删除原数据库的数据
    */
    @Test
    void delete() {
        categoryService.deleteById(1L);
    }
}

使用junit进行多分支测试

当我们编写的接口需要验证多种情况下的返回结果,可以使用junit框架内部的两个注解:@ParameterizedTest和@CsvSource,通过注解的方法,构造不同情况下的入参,以及不同入参下返回的期望结果值。其中注解@CsvSource中的value值会映射到定义好的入参中。如果入参为对象时,则需要通过实现ArgumentsAggregator类下的方法aggregateArguments,指定@CsvSource中的value转为对应的对象,并在方法入参中进行指定

  • @CsvSource参数转换为对象
import com.pdl.memory_database.domain.Category;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregationException;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;

public class CategoryArguments implements ArgumentsAggregator {
    @Override
    public Object aggregateArguments(ArgumentsAccessor argumentsAccessor, ParameterContext parameterContext)
        throws ArgumentsAggregationException {
        Category category = new Category();
        category.setName(argumentsAccessor.getString(1));
        category.setStatus(argumentsAccessor.getInteger(2));
        category.setIsDelete(argumentsAccessor.getInteger(3));
        return category;
    }
}

  • 单元测试
public class MultiBranchTest extends BaseTest {
    @Autowired
    private CategoryService categoryService;

    @ParameterizedTest
    @CsvSource(value = {
    "1,ONE", //场景1
    "2,TWO", //场景2
    "3,THREE", //场景3
    "4,OTHER" //场景4
    })
    void getTypeDesc(Integer type, String expectation) {
        String typeDesc = categoryService.getTypeDesc(type);
        Assertions.assertEquals(expectation, typeDesc);
    }

    @ParameterizedTest
    @CsvSource(value = {
        "2,蒙牛,1,0", //场景1
        "3,伊利,1,0" //场景2
    })
    void saveCategory(Long expectId, @AggregateWith(CategoryArguments.class) Category category) {
        Long id = categoryService.save(category);
        Assertions.assertEquals(id, expectId);
    }
}

使用spock框架进行多分支测试

Spockk是一个Java和Groovy应用的测试和规范框架,基于BDD(行为驱动开发)思想实现,功能非常强大。Spock结合Groovy动态语言的特点,提供了各种标签,并采用简单、通用、结构化的描述语言,让编写测试代码更加简洁、高效。以下为spock的标签及其作用

  • given:输入条件(前置参数)。

  • when:执行行为(Mock接口、真实调用)。

  • and:衔接上个标签,补充的作用。

  • then:输出条件(验证结果)。

  • with:配合then进行使用,对结果值进行校验。

  • where:通过表格的方式来测试多种分支。

    • pom

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>1.3-groovy-2.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-spring</artifactId>
    <version>1.3-RC1-groovy-2.4</version>
    <scope>test</scope>
</dependency>groovy
<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <version>2.4.6</version>
</dependency>

单元测试

import com.pdl.memory_database.domain.Category
import com.pdl.memory_database.rpc.RemoteRpc
import com.pdl.memory_database.service.CategoryService
import com.pdl.memory_database.service.SnacksService
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.test.context.ActiveProfiles
import spock.lang.Specification
import spock.lang.Unroll

/*
使用spock进行单元测试
 */
@ActiveProfiles("test")
@SpringBootTest
class SpockControllerTest extends Specification {
    @Autowired
    private CategoryService categoryService;
    @MockBean
    private RemoteRpc remoteRpc;
    @Autowired
    private SnacksService snacksService;

    @Unroll
    def "查询数据"() {
        when: "查询信息"
        def category = categoryService.findById(1)

        then: "结果"
        with(category) {
            id == 1
            name == "油炸食品"
        }
    }

    /*
    多分支测试时,返回结果比较单一时,直接用expect校验准确性
     */
    @Unroll
    def "多分支测试,参数:#type,期望:#typeDesc"() {
        expect:
        categoryService.getTypeDesc(type) == typeDesc

        where: "测试不同分支"
        type || typeDesc
        1    || "ONE"
        2    || "TWO"
        3    || "THREE"
    }

    /*
     多分支测试时,返回结果为对象,可以通过then比较对象内部值
      */
    @Unroll
    def "保存数据: #category,结果:#id"() {
        when: "保存数据"
        def categoryId = categoryService.save(category)

        then: "结果验证"
        with(categoryId) {
            categoryId == id
        }

        where: "参数"
        category                                                 || id
        new Category(null, "蒙牛酸奶", 1, 0, new Date(), new Date()) || 2
        new Category(null, "伊利酸奶", 1, 0, new Date(), new Date()) || 3
    }

    /*
    多分支测试时,需要mock时
     */
    @Unroll
    def "结合mock进行多分支测试,参数:#id,结果:#rpcResult"() {
        when:"调用远程"
        Mockito.when(remoteRpc.invork(id.toString())).thenReturn(rpcResult)
        def result = snacksService.delete(id)

        then: "结果验证"
        with(result) {
            result == rpcResult
        }
        where: "参数"
        id || rpcResult
        1  || "SUCCESS"
        2  || "FAILED"
    }
}

Junit和spock进行单元测试对比
junit spock
语法 通过Java语言进行编写 创建的测试类为Groovy class,开发语言与Java有些许不同,有特定的语法和格式,有部分写法与java完全相同,如方法调用,mock调用等,简单易学易上手
代码可读性 可读性依赖于代码编写的好坏 可通过中文对方法进行定义解释,执行步骤一目了然,可读性很高
多分支测试 当入参为对象时,需要先实现ArgumentsAggregator类定义参数转化的类,如果@CsvSource中的value内参数顺序有进行调换时,只需要修改参数转化的方法,且一个对象对应一个转化类,较为繁琐,不容易维护 可以直接通过where标签里定义不同的参数,直接new出来,无需创建转换类,更加方便,易于管理
解决远程服务调用

Mockito是mocking框架,它让你用简洁的API做测试。通过调用提供的API在执行对应方法前定义我们期望的结果,当执行到需要mock的方法时,则会返回我们需要的结果值,而不用去调用内部的业务逻辑。它不仅适用于远程服务调用,也适用于数据库调用,及内部方法调用。

  • pom
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

  • 需要mock的方法
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class RemoteRpc {
    public String invork(){
        log.info("远程服务调用");
        return "SUCCESS";
    }
}

  • 单元测试
import com.pdl.memory_database.rpc.RemoteRpc;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;

public class RemoteRpcTest extends BaseTest{
    @MockBean
    private RemoteRpc remoteRpc;

    @Test
    void invork(){
        String result = "FAIL";
        Mockito.when(remoteRpc.invork()).thenReturn(result);
        String invork = remoteRpc.invork();
        Assertions.assertEquals(result,invork);
    }
}

测试方案(建议)

使用内存数据库+spock框架+mock

项目

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容