测试驱动开发

What——什么是TDD

TDD 是测试驱动开发Test-Driven Development)的简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。 TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

Why——为什么要TDD

How——如果进行TDD

Test(目标)

进行测试之前,首先要明确测试的目标,一般为某一个特定类中的某个特定方法

如用户登录,输入正确的用户名和密码时,返回用户信息,否则抛出UserOrPasswordNotMatchedException的异常:

public interface UserService {

    /**
     * 用户登录
     *
     * @param name     用户名
     * @param password 密码
     * @return 用户信息
     * @throws UserOrPasswordNotMatchedException 当用户名或密码不匹配时,抛出该异常。
     */
    @NonNull
    User login(@NotBlank String name, @NotBlank String password);

    class UserOrPasswordNotMatchedException extends RuntimeException {
        public UserOrPasswordNotMatchedException() {
            super("用户名或密码不正确");
        }
    }
}

Assertions(断言)

Assertions(断言)用于判定目标的某一项指标是否满足测试要求,如:

针对用户登录的场景,可以写出如下断言:

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
    @InjectMocks
    private UserServiceImpl userService;

    @Test
    void should_login_success_when_found_user() {

        final User user = userService.login("user", "password");

        assertNotNull(user);
        assertEquals("user", user.getName());

    }


    @Test
    void should_throw_exception_when_not_found_user() {

        final UserOrPasswordNotMatchedException exception = assertThrows(UserOrPasswordNotMatchedException.class, () -> userService.login("user", "password"));
        assertEquals("用户名或密码不正确", exception.getMessage());

    }
}

由于UserServiceImpl为空实现,所以现在运行测试都不会通过:

org.opentest4j.AssertionFailedError: expected: not <null>
org.opentest4j.AssertionFailedError: Expected org.ifinalframework.data.mybatis.dao.mapper.UserService.UserOrPasswordNotMatchedException to be thrown, but nothing was thrown.

这个时候,来填写UserServiceImpl的实现,其依赖于UserMapper

class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    @NonNull
    @Override
    public User login(@NotBlank String name, @NotBlank String password) {

        final User user = userMapper.selectOne(new UserQuery(name, password));

        if (Objects.isNull(user)) {
            throw new UserOrPasswordNotMatchedException();
        }

        return user;

    }
}

此时,再次运行测试,should_throw_exception_when_not_found_user的提示会变成为:

org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> expected: <org.ifinalframework.data.mybatis.dao.mapper.UserService.UserOrPasswordNotMatchedException> but was: <java.lang.NullPointerException>

这是因为实现类的依赖对象UserMapper并有注入。

Mock(打桩)

Mock(打桩)用于构造测试数据以模拟真实的业务流程,减少在开发阶段对第三方接口(内部或外部)的依赖,使开发者将业务的关注点聚焦于当前测试目标的功能,而非依赖的第三方。

开发者可以使用打桩的方式,模拟各种业务场景,从而提高测试目标的健壮性。

如:

在用户登录的例子中,其实现依赖于UserMapper,而这个依赖可能还没有实现或是第三方的接口不方便于调试,这个时候,可以使用Mock来对这个对象进行打桩,以构造的方式模拟业务流程。

    @Mock
    private UserMapper userMapper;

再一次执行测试,发现should_throw_exception_when_not_found_user竟然神奇的通过了。这是因为Mock对象,对于返回引用类型的方法,默认返回null

对于找不到用户信息的场景,暂且告一段落,如果来模拟能找到用户信息的场景呢?在测试方法中添加如下代码:

    when(userMapper.selectOne(any(IQuery.class))).thenReturn(new User("user", "password"));

上述代码的意思是**当userMaper.selectOne(IQuery)方法被调用时,返回一个新的User实例。

这样就实现了使用Mock对象替代三方接口依赖了。

再次运行测试,发现两个测试都通过了。

完整测试代码如下:


@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
    @InjectMocks
    private UserServiceImpl userService;

    @Mock
    private UserMapper userMapper;

    @Test
    void should_login_success_when_found_user() {

        when(userMapper.selectOne(any(IQuery.class))).thenReturn(new User("user", "password"));

        final User user = userService.login("user", "password");

        assertNotNull(user);
        assertEquals("user", user.getName());

    }


    @Test
    void should_throw_exception_when_not_found_user() {

        final UserOrPasswordNotMatchedException exception = assertThrows(UserOrPasswordNotMatchedException.class, () -> userService.login("user", "password"));
        assertEquals("用户名或密码不正确", exception.getMessage());

    }
}

ParameterizedTest(参数化测试)

除了使用@Test进行单一的用例测试,JUnit还提供了对参数化的测试的支持,使用@ParameterizedTest注解替代@Test,并在测试方法声明参数,然后使用@ValueSource指定参数列表即可:

@Slf4j
class ParameterizedTestExampleTest {

    @ParameterizedTest
    @ValueSource(strings = {"hello", "parameterized", "test"})
    void parameterizedTest(String parameter) {
        logger.info(parameter);
    }

}

@ValueSource支持基本类型String

ArgumentCaptor(参数捕获)

ArgumentCaptor可用于捕获目标方法内的过程参数,以验证目标方法是否按照预期流程执行和参数是否正确。

    // 实例化一个参数捕获器
    ArgumentCaptor<Person> argument = ArgumentCaptor.forClass(Person.class);
    // 执行目标方法    
    verify(mock).doSomething(argument.capture());
    // 校验捕获的参数    
    assertEquals("John", argument.getValue().getName());

Tools(工具)

Jacoco

Jacoco是一个测试覆盖率报告生成插件,集成该插件可以在项目构建时自动执行测试并生成测试报告,同时可设置测试指标,如果指标未达成,可强制结束构建直到测试指标达标。

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>**/*Entity.java</exclude>
            <exclude>**/*Entity.class</exclude>
        </excludes>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>
mvn clean test

在浏览器中打开生成的测试报告,路径为:

/target/site/jacoco/index.html

Git HooK

Git Hook是一种勾子函数,可在执行git相关命令时,触发相关脚本的执行,如在git commit之前执行测试,以避免将有缺陷的代码提交到仓库中。

在项目根目录下添加.githook/pre-commit文件,内容如下:

#!/bin/sh
#execute shell before commit,check the code

mvn test
#得到检测结果,没有问题 执行结果为0;有问题 执行结果为非0
check_result=$?
if [ $check_result -eq 0 ]
then 
    echo "项目执行Test检测成功!!!"
else    
    echo "提交失败,源于项目存在代码测试问题(mvn test)"
    exit 1
fi

原则