测试用例

提到测试,所有人都知道它的重要性,但大部分开发人员都会觉得测试应该交给测试人员来做,或者直接到环境上执行一下、点一点看看没有问题就可以了。

究其原因,个人觉得是因为写单元测试的成本太高了,尤其是对外部环境依赖较多的项目,开发环境想运行起来都要依赖特定的组件,就更别提测试用例的运行了。

一个好的测试用例,应该是自动化的、可重复执行的,容易理解、容易运行的,且有明确的断言,以便在代码出现不符合预期的结果时,能够快速发现且定位到问题的。

本文以一些面向 Java 和 Spring 的常见测试场景为例,介绍一种简单的测试用例写法。

Scaffold

先来介绍几个脚手架,可以有效降低写测试用例的难度。

Groovy

Groovy 是一种运行在 JVM 上的动态语言,与 Java 有着极佳的兼容性,并且有着更加简洁的语法。

使用 Groovy 的 Killer App 也有不少,比如著名的 GradleGrails,如果在生产环境中使用还有顾虑,不妨先在测试用例中体会 Groovy 的魅力。

说 Groovy 与 Java 有着极佳的兼容性,是因为直接将一个 Java 文件的扩展名从 .java 改为 .groovy 你就得到了一个合法的 Groovy 文件,可以在 .groovy 的文件中混合使用 Java 和 Groovy 的语法,非常的方便。IDEA 对 Groovy 也有着开箱即用的支持,可以借助 IDEA 的提示,边用边学 Groovy 的语法。

通过一个嵌套 Map 和类型转换的例子,简单对比一下相同功能的 Java 和 Groovy 代码:

Java:

Map<String, String> nested = new HashMap<>();
nested.put("nested_key", "nested_value");
Map<String, Object> outer = new HashMap<>();
outer.put("outer_key", "outer_value");
outer.put("nested", nested);

System.out.println(((Map)outer.get("nested")).get("nested_key"));

Groovy:

def outer = [outer_key: 'outer_value', nested: [nested_key: 'nested_value']]

println outer.nested.nested_key

感受到 Groovy 的简洁了吧!

在一个 Java 项目中,可以将 Java 源码放在 src/main/java 目录下,将 Groovy 测试用例源码放在 src/test/groovy 目录下,并为项目添加 Groovy 的相关插件,这样就可以在 src/test/groovy 目录下直接运行 Groovy 的测试用例了。

Gradle 项目中添加 Groovy 相关插件比较简单,只需在 build.gradle 文件中添加如下内容:

plugins {
    id 'groovy'
}

dependencies {
    implementation 'org.codehaus.groovy:groovy'
}

Maven 项目需要在 pom.xml 文件中添加如下内容:

<dependencies>
    <dependency>
        <groupId>org.codehaus.groovy</groupId>
        <artifactId>groovy</artifactId>
    </dependency>
</dependencies>

<plugins>
    <plugin>
        <groupId>org.codehaus.gmavenplus</groupId>
        <artifactId>gmavenplus-plugin</artifactId>
    </plugin>
</plugins>

在 Spring Initializr( https://start.spring.io/ )中,可以直接生成 Groovy 语言的 Maven 或 Gradle 项目,可用作配置的参考。

Spock Framework

Spock Framework 是一个面向 Java 和 Groovy 应用的测试框架,基于这个框架写出的测试用例,具有更高的可读性,例如:

@Unroll
def "Pinyin of #input is #result"() {
    expect:
    result == PinyinUtil.quanpin(input)

    where:
    input       | result
    ''          | ''
    null        | ''
    '好似'       | 'haosi'
    '似的'       | 'shide'
    '混1a搭'     | 'hun1ada'
    '绿'         | 'lv'
}

上面的测试用例,会使用一组输入数据(input)传入拼音工具类的全拼方法中,并校验转换结果是否与结果列(result)中对应的值一致,在出现不一致的情况时,会直接输出具体失败的那组数据,如 Pinyin of 似的 is shide 用例执行失败。

spock

Spring Roll

Spring Roll 是一个 Side Project,其中的 roll-test 模块集成了 Spock Framework,并提供了一些测试类的基类,引入 roll-test 模块的方法可参考 How to use

目前,Spring Roll 主要提供以下三个基类:

  1. AbstractSpringTest:用于需要 Spring 上下文的测试用例,包含常用的 RESTful 接口测试方法
  2. AbstractSpringTxTest:继承自 AbstractSpringTest,并提供了事务支持,可用于需要事务的测试用例
  3. AbstractIntegrationTest:提供一种集成测试方案,会在随机端口真正启动 Web 应用,并提供与 AbstractSpringTest 一致的 RESTful 接口测试用法

下面基于一些常见的测试场景,来介绍测试用例的写法。

测试 Java 类

当要测试的类是一个 POJO(Plain Old Java Object),并不依赖 Spring 框架时,可直接使测试类继承 Spock Framework 提供的 Specification 基类,如:

class AntResourceUtilSpec extends Specification {

    def "Support multi-locations separated by comma"() {
        def res = AntResourceUtil.getResources('classpath*:**/ant1.test,classpath*:**/springroll/**/*.test')

        expect:
        // According to actual files under path pattern
        res.length == 2
    }

}

expect: 上面,可以编写测试的准备工作代码,如准备 res 的内容;expect: 下面,编写测试用例的断言,即期望的行为。如果需要使用一批数据,校验方法的行为,可像上面拼音工具类的测试用例一下,在 expect: 部分后面增加一个 where: 块,用来提供测试数据。

测试 Spring Bean

在基于 Spring 进行开发时,更多的情况下我们的测试目标类可能是一个 Spring Bean,这时可使测试类继承自 AbstractSpringTest

例如我们有一个 ExportExcelController 如下:

@Controller
@RequestMapping("/export/excel")
public class ExportExcelController {

    @GetMapping("/{title}")
    public void export(@PathVariable String title, @RequestParam String cols, @RequestParam String url,
                       String tomcatUriEncoding, HttpServletRequest request, HttpServletResponse response) throws Exception {
        // ...
    }

}

测试用例中,希望验证这个 Get 接口的行为,可以这样写:

class ExportExcelControllerTest extends AbstractSpringTest {

    @Test
    void test() {
        def cols = '[{"display":"名称","name":"name","showTitle":true,"field":"name","hidden":false,"label":"名称","prop":"name","title":"名称"},{"label":"名称","prop":"name","width":"40"}]'
        cols = URLEncoder.encode(cols, 'UTF-8')
        def url = URLEncoder.encode('/test/query', 'UTF-8')

        get("/export/excel/abc?cols=$cols&url=$url", HttpStatus.OK)
    }
}

其中,get("/export/excel/abc?cols=$cols&url=$url", HttpStatus.OK) 意为使用 Get 方法调用 /export/excel/abc 接口,传入两个必须的参数,并预期返回状态码为 200

MvcResult get(String url, HttpStatus statusCode) 类似,在 AbstractSpringTest 基类中,还提供了 postputdelete 等 RESTful 接口常用的各类方法的测试方法。为方便对接口返回内容进行进一步的验证,还有 resOfGetresOfPostresOfPut 方法,直接获取接口的返回对象,例如:

@Test
void test() {
    assert resOfGet('/app/node', HttpStatus.OK).data.startsWith('http')
}

AbstractSpringTest 中定义的属性和方法如下图,大部分均为 protected 修饰,可被子类直接使用。

outline

测试数据库相关操作

事务

在测试数据库相关的操作时,一般情况下不希望测试数据被真正的插入到数据库中,造成垃圾数据或测试方法之间的互相影响,此时可以使测试类继承 AbstractSpringTxTest 基类,测试数据会在测试用例执行完毕后自动回滚。

如果需要真正将数据插入到数据库中,可以在类或方法上使用 @NoTx 注解。

使用内存数据库减少测试环境依赖

为了测试用例能够更容易的被执行,应尽量减少测试用例对环境的依赖。数据库就是其中一个最主要的依赖。如果你的项目使用了一些数据库无关的 ORM 框架,可以方便的将测试环境所使用的数据库切换到内存数据库上。但如果你的项目是与具体数据库绑定的,这件事做起来就没有那么容易了。

如果你的项目使用的是 MySQL,项目中包含了必需的数据库脚本,那么可以尝试使用 roll-test 模块所提供的 MysqlTranslateToH2Executor 来自动完成 MySQL 脚本至 H2 脚本的转换,并在 H2 中执行这些脚本。

在项目中依赖了 roll-test 模块后,可以通过设置 roll.test.datasource.type=mysql2h2 参数来启用此功能,默认会扫描符合 classpath*:sql/**/*.sql 路径模式的所有脚本文件,可通过 roll.test.datasource.mysql-scripts 参数调整扫描路径。

集成测试

继承 AbstractSpringTest 的测试用例,是通过 MockMvc 对象来模拟 HTTP 请求的。如果希望通过真实的 HTTP 请求调用 RESTful 接口,可以继承 AbstractIntegrationTest 基类,比如想测试一下项目配置的 AllowCrossOriginFilter 是否生效:

class AllowCrossOriginFilterTest extends AbstractIntegrationTest {

    @Test
    void checkResponseHeaders() {
        def connection = get('/test', HttpStatus.NOT_FOUND)
        def headers = connection.headerFields
        assert headers.containsKey('Access-Control-Allow-Credentials')
        assert headers.containsKey('Access-Control-Allow-Headers')
        assert headers.containsKey('Access-Control-Allow-Methods')
        assert headers.containsKey('Access-Control-Allow-Origin')
        assert headers.containsKey('Access-Control-Expose-Headers')
        assert headers.containsKey('Access-Control-Max-Age')

        options('/test', HttpStatus.OK)
    }

}

AbstractIntegrationTest 基类提供了与 AbstractSpringTest 类似的测试 RESTful 接口的方法,这意味着可以简单的修改测试类的基类,以在单元测试和集成测试之间切换。

integration

集成测试基类会将服务运行在一个随机端口上,测试用例中可通过基类中的 port 属性获取服务运行的端口号。

测试条件 Bean

当项目中存在条件 Bean 时,例如使用 @ConditionalOnProperty 注解来指定当某个属性为某个值时,该 Bean 才会被加载,在测试用例中可通过 @TestPropertySource 注解或直接使用 @SpringBootTest 注解来指定测试条件的属性值,例如:

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
    properties = [
        'sms.templateId=123456',
        'sms.url=http://localhost:8080',
])
@TestPropertySource(properties = ['sms.type = phone'])
class SmsSendServiceImplTest extends AbstractIntegrationTest {

    @Autowired
    SmsSendService service

    @Test
    void test() {
        assert service.sendSms([
            Sms.builder().smsPhone('123456').smsContent('content1').build(),
            Sms.builder().smsPhone('123456').smsContent('content2').build()
        ]) == 2
    }

}

假设上例中,SmsSendService 有多种实现类,根据不同的 sms.type 进行注册:

@ConditionalOnProperty(value = "sms.type", havingValue = "phone")
public class SmsSendServicePhoneImpl implements SmsSendService {
    // ...
}

@TestPropertySource(properties = ['sms.type = phone']) 相当于在配置文件中设置了 sms.type=phone,这样 SmsSendServicePhoneImpl 就会被注入到 service 属性中。

测试依赖三方服务的类

本地 Controller 替代三方 HTTP 服务

上例中的 SmsSendServicePhoneImpl 实现类会向 sms.url 地址所代表的短信网关发送一个 HTTP 请求,在获得 200 响应后,认为短信发送成功。

因为测试环境可能也没有短信网关环境,这时候可以将集成测试启动在本地的固定端口(8080)上,并将短信网关的地址指向集成测试服务,即上例中的:

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
    properties = [
        'sms.templateId=123456',
        'sms.url=http://localhost:8080',
])

这样就可以在测试用例中,编写响应替代短信网关服务接口的 RestController,来完成测试用例的内容了。

MockBean

除了实现替代接口外,还可以使用 Mock 的方式,为三方服务接口模拟响应。

还是以上面的短信服务为例,假设 SmsService 接口的 SmsServiceRestImpl 实现是通过 RestTemplate 从一个外部接口获得短信列表,
在测试用例中,可通过 @MockBeanMockito 来模拟调用 RestTemplate 方法时的返回内容,例如:

class SmsServiceRestImplTest extends AbstractSpringTest {

    @Autowired
    private SmsService smsService

    @MockBean
    private RestTemplate restTemplate

    @Test
    void test() {
        def vo = new PageVO()
        vo.list = [
                [smsContent: 'a'],
                [smsContent: 'b'],
                [smsContent: 'c']
        ]
        def res = new ResponseEntity<PageVO>(vo, HttpStatus.OK)
        Mockito.when(restTemplate.getForEntity(Mockito.anyString(), Mockito.any(Class.class))).thenReturn(res)

        smsService.restTemplate = restTemplate

        def result = smsService.list()
        assert result.size() == vo.list.size()
        assert result[1].getSmsContent() == vo.list[1]['smsContent']
    }

}
0%