七的博客

单元测试最佳实践

开发经验

单元测试最佳实践

总结在项目中编写单元测试用例的一些经验技巧。

开发语言: Java

测试工具: Junit 5 + Mockito

虽说案例可能是 Java 语言相关的,但是一些思想以及做法基本是编程语法通用的,不要局限于某一门编程语言。

1. 测试用例之间互相隔离

单元测试用例是为了验证单一的方法正确性,如果测试用例之间不是独立的,那么测试用例结果可能会互相影响。这种测试结果挺可怕的,比较典型的就是共享状态,。

比如有一个计算器的测试用例,反例如下:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    
    // 共享这个计算器实例
    private static Calculator calculator = new Calculator();
    // 共享的静态变量
    private static int sharedValue = 0;

    @Test
    void testAdd() {
        sharedValue += calculator.add(2, 3);
        
        assertEquals(5, sharedValue);
    }

    @Test
    void testSubtract() {
        sharedValue = calculator.subtract(sharedValue, 2);
        
        assertEquals(3, sharedValue);
    }
}

上面这个测试用例看着没明显的问题,但是实际上运行的时候就会有好几种结果。

  • 如果 testAdd() 方法先运行,再运行 testSubtract() 方法,两个测试用例都可以通过。
  • 如果 testSubtract() 方法先运行,这个测试用例会失败,因为 sharedValue 这个变量初始化的时候为 0。
  • 重复多次运行这个测试用例,sharedValue 这个变量值会一直变化,测试用例的结果就会没法预测。

正例:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    @Test
    void testAddition() {
        Calculator calculator = new Calculator();
        assertEquals(5, calculator.add(2, 3));
    }

    @Test
    void testSubtraction() {
        Calculator calculator = new Calculator();
        assertEquals(1, calculator.subtract(3, 2));
    }
}

像上面一样,测试用例之间互相隔离,就不会出现各种不可预测的结果了。

2. 测试用例的资源准备以及清理

保证每一个测试用例都在一个干净的环境中执行是非常重要的,通常会分为资源的准备 (SetUp) 以及资源的清理( Teardown ) 过程。

常见的资源准备包括下面这几种:

  • 创建一些测试的对象。

  • 初始化一些公共的变量。

  • 构造 Mock 对象。

常见的资源清理是测试用例执行完之后的一些收尾工作,比如:

  • 关闭数据库连接或者其他的连接。

  • 删除一些测试过程中产生的临时文件等等。

  • 重置一些变量的值。

  • 清空缓存。

在 Junit 中,使用 @BeforeEach 注解标记在某个方法上,这个方法用来准备环境。使用 @AfterEach 注解来标记某个方法,这个方法中包含资源清理的逻辑。

3. 每个测试用例只测试一种场景

每个测试用例应该专注于验证一个特定的场景或者行为,不要将多个方法的验证逻辑放在一起。

反例:

 @Test
 void testUserOperations() {
    final User user = new User("admin1062", "admin@xxx.com");
    
    // 测试创建用户,然后查询所有的用户数量
    assertTrue(userService.createUser(user));
    assertEquals(1, userService.getUserCount());
    
    
    // 测试删除用户,然后查询所有的用户数量
    assertTrue(userService.deleteUser(user.getId()));
    assertEquals(0, userService.getUserCount());
}

上面这个测试用例就包含了新增跟删除的逻辑校验,这样是不太合理的。

  • 通过方法名不知道这个方法具体是干什么的。
  • 测试用例如果失败的话,还需要点进去查看到底是哪一块的逻辑校验失败。

正例:

@Test
void testCreateUser() {
    final User user = new User("admin1062", "admin@xxx.com");
    assertTrue(userService.createUser(user));
    assertEquals(1, userService.getUserCount());
}


@Test
void testDeleteUser() {
    final User user = new User("admin1062", "admin@xxx.com");
    
    assertTrue(userService.deleteUser(user.getId()));
    assertEquals(0, userService.getUserCount());
}

改进后:

  • 每个测试用例专注于验证一个方法的操作。
  • 测试用例的名称可以清晰的看出作用。
  • 哪个测试用例失败一眼就能看出来。

需要注意的是,并不是说一个测试用例只能有一个断言。在复杂的逻辑测试用例中,会同时使用多个断言来验证,只是需要保证是针对同一个行为的校验。

4. 按照 AAA 模式编写测试用例代码

测试用例的代码编写顺序可以按照 Arrange-Act-Assert 的顺序来编写,具体的作用是:

  • Arrange 就是准备测试数据和环境。
  • Act 就是执行要测试的方法。
  • Assert 就是验证执行结果。

比如上面的创建用户的方法的测试逻辑按顺序就是这样编写:

    @Mock
    private UserRepository userRepository;

    @Test
    void testCreateUser() {
        // 准备数据
        final User user = new User("admin1062", "admin@xxx.com");
        when(userRepository.save(user)).thenReturn(true);

        //  执行测试方法
        final boolean result = userService.createUser(user);

        //   断言结果
        assertTrue(result);
        verify(userRepository).save(user);
    }

反例:

@Test
void testCreateUser() {
    final User user = new User("admin1062", "admin@xxx.com");
    
    assertTrue(userService.createUser(user));
    
    when(userRepository.save(user)).thenReturn(true);
    
    final boolean result = userService.createUser(user);
    
    verify(userRepository).save(user);
    
    assertTrue(result);
  
}

上面这个方法看着就会很乱,没有分离这几个步骤的代码。

5. 避免重复的测试逻辑代码

重复的测试逻辑对后期测试用例的维护造成了很大的困难,同时还造成代码不太好阅读。

比较常见的地方就是测试用例中的一些数据对象进行验证。

    @Test
    void testCreateUser() {
        final User user = new User("admin1062", "admin@xxx.com");
        
        when(userService.createUser("admin1062", "admin@xxx.com")).thenReturn(user);

        User createdUser = userService.createUser("admin1062", "admin@xxx.com");

        assertNotNull(createdUser);
        assertEquals("1", createdUser.getId());
        assertEquals("admin1062", createdUser.getUsername());
        assertEquals("admin@xxx.com", createdUser.getEmail());
    }


    @Test
    void testGetUserById() {
        final User user = new User("admin1062", "admin@xxx.com");
        when(userService.getUserById("1")).thenReturn(user);

        User retrievedUser = userService.getUserById("1");

        assertNotNull(retrievedUser);
        assertEquals("1", retrievedUser.getId());
        assertEquals("admin1062", retrievedUser.getUsername());
        assertEquals("admin@xxx.com", retrievedUser.getEmail());
    }


上面两个方法中,校验 User 对象的逻辑是通用的,完全可以抽取成一个公共的校验函数。

    @Test
    void testCreateUser() {
        final User user = new User("admin1062", "admin@xxx.com");
        
        when(userService.createUser("admin1062", "admin@xxx.com")).thenReturn(user);

        User createdUser = userService.createUser("admin1062", "admin@xxx.com");

        assertUserDetails(createdUser);

    }


    @Test
    void testGetUserById() {
        final User user = new User("admin1062", "admin@xxx.com");
        when(userService.getUserById("1")).thenReturn(user);

        User retrievedUser = userService.getUserById("1");

        assertUserDetails(retrievedUser);
    }


     private void assertUserDetails(final User user) {
        assertNotNull(user);
        assertEquals("1", user.getId());
        assertEquals("admin1062", user.getUsername());
        assertEquals("admin@xxx.com", user.getEmail());
    }


通过这个 assertUserDetails 方法可以封装重复的断言逻辑,避免代码重复。

6. 给测试用例取个好的名称

一个好的测试用例名称可以清晰表达出测试的目的,测试的场景。 通过名字可以直观地的看出来目的,特别是团队协作的时候尤为重要。

测试用例名称可以往下面几点靠:

  • 通过名称可以表名测试的功能以及预期的结果。
  • 通过单词来区分不同的测试场景。
  • 名称不要太长,太长影响阅读。
  • 命名风格保持一致。

不太好的例子:

@Test
void test1();  // 取名太随意,看不出来测试的含义

@Test
void testUser();  // 过于模糊,看不出来具体要测试用户什么


@Test
void test_user_creation_when_input_is_valid_and_database_is_available();
// 太长,难以快速阅读

恰当的测试用例名称:

@Test
void createUser_shouldSucceed();

@Test
void getUserById_shouldReturnUser();


@Test
void getUserById_shouldThrowNotFoundException();

7. 编写边界测试条件用例

边界条件测试可以确保代码在各种情况下都能正常的运行,比较常见的边界条件包括:

  • 测试 0、 空值、 null 等,这几个测试条件往往一测就出问题。
  • 测试最大值 + 1、最小值 - 1这种无效值,通常也容易出问题。
  • 第一个元素或最后一个元素,容易测出来越界的 bug。
  • 特别大的数值或者特别小的数值。

8. 屏蔽外部服务依赖

因为单元测试只是测试一个小的方法,所以不能受外部因素的影响。但是在实际的业务代码中,依赖外部的组件或者 API是不可避免的,所以需要屏蔽这个外部服务的依赖。

屏蔽的优点有以下几点:

  • 提高测试的可靠性。外部服务不稳定或不可用都会影响单元测试。
  • 加快测试执行速度。 单元测试就是要快,但是访问外部服务往往都是比较慢的。
  • 可以模拟各种场景,包括错误、异常情况。

用的比较多的就是 Mock, 在 Java 中使用的 Mock 工具就是 Mockito 。

比如一个注册用户的逻辑,会涉及到发送邮件。

public interface EmailService {
    void sendVerificationEmail(String to, String verificationCode);
}

public class UserService {
    private final EmailService emailService;
    private final UserRepository userRepository;

    public UserService(EmailService emailService, UserRepository userRepository) {
        this.emailService = emailService;
        this.userRepository = userRepository;
    }

    public void registerUser(String username, String email) {
        final User newUser = new User(username, email);
        userRepository.save(newUser);

        String verificationCode = "123456";

        // 发送验证码邮件
        emailService.sendVerificationEmail(email, verificationCode);
    }

}

这里就涉及到一个邮件服务,在写单元测试的时候我们就可以使用 Mockito 来 mock 这个邮件服务。

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {

    // mock 这个邮件服务
    @Mock
    private EmailService emailService;

    @Mock
    private UserRepository userRepository;

    private UserService userService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        userService = new UserService(emailService, userRepository);
    }

    @Test
    void test_registerUser() {
        String username = "admin1062";
        String email = "admin1062@xxxx.com";

        // 设置这个邮件服务默认会返回 null。
        when(userRepository.findByEmail(email)).thenReturn(null);

        userService.registerUser(username, email);

        verify(userRepository).save(any(User.class));
        verify(emailService).sendVerificationEmail(eq(email), anyString());
    }

}


在上面这个测试用例中,通过模拟 userRepository.findByEmail() 返回 null,表示用户不存在,让单元测试可以按指定流程运行。

同时也可以模拟各种情况,如用户已存在或不存在。

9. 异常情况的测试

异常情况是单元测试中很重要的一个环节,通常是模拟一些特殊的流程处理。通过单元测试中的各种异常情况,可以让开发考虑到需要处理各种失败的情况。

在生产环境的 bug ,往往大部分情况下都是由于异常情况导致的,因为明显的 bug都已经在测试环境被检测出来了。

还是以用户注册的例子。

public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;

    public UserService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }

    public void registerUser(String username, String email, String password) throws InvalidInputException, UserAlreadyExistsException {
    if (username == null) {
        throw new InvalidInputException("用户名不能为空");
    }
    if (username.trim().isEmpty()) {
        throw new InvalidInputException("用户名不能只包含空白字符");
    }

    if (email == null) {
        throw new InvalidInputException("邮箱地址不能为空");
    }
    if (email.trim().isEmpty()) {
        throw new InvalidInputException("邮箱地址不能只包含空白字符");
    }

    if (password == null) {
        throw new InvalidInputException("密码不能为空");
    }
    if (password.length() < 8) {
        throw new InvalidInputException("密码长度不能少于8个字符");
    }

    if (userRepository.findByEmail(email) != null) {
        throw new UserAlreadyExistsException("该邮箱已被注册");
    }

    User newUser = new User(username, email, password);
    userRepository.save(newUser);
    emailService.sendWelcomeEmail(email);
  }

}  

除了常规的验证注册用户成功的用例外,也需要额外的验证用户名、密码、邮件地址等非空,代码里面还可以增加包括正则等校验。

异常情况测试用例如下:

@Test
void registerUser_withNullUsername() {
    InvalidInputException e = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser(null, "admin1062@xxxx.com", "password123");
    });
    assertEquals("用户名不能为空", e.getMessage());
}

@Test
void registerUser_withEmptyUsername() {
    InvalidInputException e = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser("   ", "admin1062@xxxx.com", "password123");
    });
    assertEquals("用户名不能只包含空白字符", e.getMessage());
}

@Test
void registerUser_withNullEmail() {
    InvalidInputException e = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser("admin1062", null, "password123");
    });
    assertEquals("邮箱地址不能为空", e.getMessage());
}

@Test
void registerUser_withEmptyEmail() {
    InvalidInputException e = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser("admin1062", "  ", "password123");
    });
    assertEquals("邮箱地址不能只包含空白字符", e.getMessage());
}

@Test
void registerUser_withNullPassword() {
    InvalidInputException exception = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser("admin1062", "admin1062@xxxx.com", null);
    });
    assertEquals("密码不能为空", exception.getMessage());
}

@Test
void registerUser_withShortPassword() {
    InvalidInputException e = assertThrows(InvalidInputException.class, () -> {
        userService.registerUser("admin1062", "admin1062@xxxx.com", "short");
    });
    assertEquals("密码长度不能少于8个字符", e.getMessage());
}

10. 测试私有方法

测试 private 方法是有争议的,因为私有方法是不对外暴露的,所以不应该去测试这种细节。

但是测试 private 方法在某些场景下很实用:

  • 如果代码逻辑复杂,必然会通过拆分多个小方法,这个时候直接测试 private 方法会非常便利,不要额外模拟很多前置逻辑。
  • 测试 private 方法可以提升测试覆盖率,同时直接跳过 private 方法也容易隐藏一些 bug。

测试 private 方法的缺点也很明显:

  • private 方法容易变动,那么意味着测试用例需要经常变动。
  • private 方法是私有的,不应该被外界访问,包括测试用例。

包括像 Mockito 这个库也是一样的理念,作者从一开始就声明不太支持测试私有方法,比如文档里面就写着:

Can I mock private methods? No. From the standpoint of testing… private methods don’t exist. More about private methods here.

Firstly, we are not dogmatic about mocking private methods. We just don’t care about private methods because from the standpoint of testing, private methods don’t exist. Here are a couple of reasons Mockito doesn’t mock private methods:

  1. It requires hacking of classloaders that is never bullet proof and it changes the API (you must use custom test runner, annotate the class, etc.).
  2. It is very easy to work around - just change the visibility of method from private to package-protected (or protected).
  3. It requires the team to spend time implementing & maintaining it. And it does not make sense given point (2) and a fact that it is already implemented in different tool (powermock).
  4. Finally… Mocking private methods is a hint that there is something wrong with Object Oriented understanding. In OO you want objects (or roles) to collaborate, not methods. Forget about pascal & procedural code. Think in objects.

通常测试私有方法会通过反射去调用,比如一开始的那个计算器的例子:

@Test
void testAdd() throws Exception {
    final Calculator calculator = new Calculator();
    final Method addMethod = Calculator.class.getDeclaredMethod("add", int.class, int.class);
    addMethod.setAccessible(true);

    final int result = (int) addMethod.invoke(calculator, 2, 3);
    assertEquals(5, result);
}

这里就是通过反射去测试 private 方法,大多数情况下不建议去测试。

11. 测试静态方法

静态方法在项目中通常会出现在工具类中,而且到处都在调用。这种方法频繁调用,所以写相应的单元测试也比较有价值。

普通的静态方法是好测试的,特别是有返回值的那种,比如下面这个方法比较典型:

public class MathUtils {
    public static int add(int a, int b) {
        return a + b;
    }
}

@Test
void testAdd() {
    assertEquals(5, MathUtils.add(2, 3));
}

这种通过返回值校验即可,但是如果要控制静态方法的逻辑,那么就会变得比较困难。比如下面这个验证用户信息的工具类:

public class UserService {
    public boolean registerUser(User user) {
        if (UserValidator.validateUsername(user.getUsername())) {
            // 执行注册逻辑
            return true;
        }
        return false;
    }
}

public class UserValidator {
    public static boolean validateUsername(String username) {
        return username != null && username.length() >= 3 && username.length() <= 20;
    }
}

假设这里要想让 UserValidator.validateUsername() 方法返回我们想要的值,就会变得有点困难。

通常我们会结合 PowerMock 去 mock 这个静态方法,让它返回我们想要返回的值:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;

import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.anyString;

@RunWith(PowerMockRunner.class)
@PrepareForTest(UserValidator.class)
public class UserServiceTest {

    @Test
    public void testRegisterUser_ValidUsername() {
        final User user = new User("有效的用户");

        // 使用 powermock 去 mock 掉UserValidator.validateUsername() ,估计返回 true
        PowerMockito.mockStatic(UserValidator.class);
        PowerMockito.when(UserValidator.validateUsername(anyString())).thenReturn(true);

        UserService userService = new UserService();
        boolean result = userService.registerUser(user);

        assertTrue(result);
    }

    @Test
    public void testRegisterUser_InvalidUsername() {
        User user = new User("无效的用户名");

        // 使用 powermock 去 mock 掉UserValidator.validateUsername() ,估计返回 false
        PowerMockito.mockStatic(UserValidator.class);
        PowerMockito.when(UserValidator.validateUsername(anyString())).thenReturn(false);

        UserService userService = new UserService();
        boolean result = userService.registerUser(user);

        assertFalse(result);
    }
}