跳到主要内容

测试基础:从零开始理解软件测试

🤔 为什么要写测试?

真实场景:没有测试的痛苦

想象一下这个场景:

// 你写了一个用户注册功能
public void register(UserDTO user) {
// 检查用户名是否存在
if (userMapper.selectByUsername(user.getUsername()) != null) {
throw new BusinessException("用户名已存在");
}

// 密码加密
user.setPassword(passwordEncoder.encode(user.getPassword()));

// 保存到数据库
userMapper.insert(user);

// 分配默认角色
roleMapper.assignDefaultRole(user.getId());
}

三个月后,你的同事修改了这个方法:

public void register(UserDTO user) {
// 【BUG】忘记检查用户名是否存在!
// if (userMapper.selectByUsername(user.getUsername()) != null) {
// throw new BusinessException("用户名已存在");
// }

user.setPassword(passwordEncoder.encode(user.getPassword()));
userMapper.insert(user);
roleMapper.assignDefaultRole(user.getId());
}

没有测试的后果

  • ❌ 代码上线后才发现能重复注册
  • ❌ 用户投诉激增
  • ❌ 花费大量时间定位问题
  • ❌ 紧急修复和回滚
  • ❌ 你的同事背锅

有测试的情况

  • ✅ 修改代码后运行测试
  • ✅ 测试立即失败:should_throw_exception_when_username_exists
  • ✅ 5 秒内发现问题
  • ✅ 修复后再次运行,测试通过
  • ✅ 从未上线到生产环境

测试的真正价值

价值说明实际案例
🔒 安全网重构代码时不怕破坏功能你重构了 10 个方法,测试告诉你哪 2 个出问题了
📖 活文档展示代码如何使用新同事看测试就知道 API 怎么调用
🐛 快速定位精准定位问题所在测试失败 = 告诉你哪一行代码错了
💪 信心敢于改进代码有测试保护,你可以放心优化性能
⏱️ 长期收益初期投入,长期节省时间写 1 小时测试,省下 10 小时调试

核心观点:测试不是为了"达到覆盖率指标",而是为了保护你的代码不被破坏


📊 测试覆盖率的真相

误区 1:覆盖率越高越好

// ❌ 糟糕的测试:覆盖率 100%,但毫无意义
@Test
void testRegister() {
userService.register(new UserDTO()); // 没有验证任何行为!
}
// ✅ 好的测试:覆盖率可能只有 60%,但测试了关键逻辑
@Test
void should_throw_exception_when_username_exists() {
// Given
when(userMapper.selectByUsername("admin")).thenReturn(existingUser);

// When & Then
assertThrows(BusinessException.class, () -> {
userService.register(createUser("admin"));
});
}

什么是"好的"覆盖率?

组件推荐覆盖率原因
核心业务逻辑 (Service)80%+这是你的价值所在,必须测试
Controller60%+主要测试参数验证和错误处理
工具类 (Utils)90%+无状态、易测试,应该全覆盖
Entity/DTO0%数据类,无需测试 getter/setter

覆盖率的正确理解

覆盖率 ≠ 代码质量
覆盖率 = 测试执行过的代码行数 / 总代码行数

关键在于:
✅ 测试是否覆盖了关键业务逻辑?
✅ 测试是否覆盖了异常分支?
✅ 测试是否覆盖了边界条件?

权威观点

"测试覆盖率是一个有用的工具,但它只能告诉你哪些代码没有被测试,而不能告诉你测试的质量如何。"
—— Martin Fowler, 《重构:改善既有代码的设计》


🧪 JUnit 5 核心概念

1. 测试方法注解

import org.junit.jupiter.api.*;

class MyTest {

@BeforeAll // 所有测试开始前执行一次(静态方法)
static void setupClass() {
System.out.println("启动测试类");
}

@BeforeEach // 每个测试方法前执行
void setUp() {
System.out.println("准备测试环境");
}

@Test // 标记为测试方法
@DisplayName("应该成功创建用户") // 友好的测试名称
void should_create_user() {
// 测试逻辑
}

@AfterEach // 每个测试方法后执行
void tearDown() {
System.out.println("清理测试环境");
}

@AfterAll // 所有测试结束后执行一次(静态方法)
static void tearDownClass() {
System.out.println("关闭测试类");
}
}

2. 断言(Assertions)

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

@Test
void testAssertions() {
// 基本断言
assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);

// 异常断言
assertThrows(BusinessException.class, () -> {
userService.register(invalidUser);
});

// 超时断言
assertTimeout(Duration.ofSeconds(2), () -> {
userService.slowOperation();
});

// 组合断言
assertAll("用户验证",
() -> assertEquals("admin", user.getUsername()),
() -> assertNotNull(user.getEmail()),
() -> assertTrue(user.getAge() > 0)
);
}

3. 参数化测试

@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
@DisplayName("应该拒绝空白用户名")
void should_reject_blank_username(String username) {
UserDTO user = new UserDTO();
user.setUsername(username);

assertThrows(ValidationException.class, () -> {
userService.register(user);
});
}

@ParameterizedTest
@CsvSource({
"admin, 123456, true",
"user, password, true",
"test, , false" // 密码为空,应该失败
})
void should_validate_credentials(String username, String password, boolean expected) {
boolean result = authService.validateCredentials(username, password);
assertEquals(expected, result);
}

🌱 Spring Test 核心概念

1. @SpringBootTest - 完整集成测试

@SpringBootTest  // 启动完整的 Spring 应用上下文
@AutoConfigureMockMvc // 自动配置 MockMvc
class UserControllerIntegrationTest {

@Autowired
private MockMvc mockMvc; // 注入真实的 Bean

@Autowired
private UserMapper userMapper; // 使用真实数据库

@Test
@Transactional // 测试后自动回滚数据库
void should_create_user_in_database() throws Exception {
// Given
String requestBody = """
{
"username": "testuser",
"password": "password123"
}
""";

// When
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated());

// Then - 验证数据库中真的创建了用户
User savedUser = userMapper.selectByUsername("testuser");
assertNotNull(savedUser);
}
}

何时使用

  • ✅ 测试真实的数据库交互
  • ✅ 测试 Spring Security 配置
  • ✅ 测试事务管理
  • ⚠️ 缺点:启动慢(每次几秒)

2. @Mock 和 @InjectMocks - 单元测试

@ExtendWith(MockitoExtension.class)  // 不启动 Spring
class UserServiceTest {

@Mock // 创建一个"假的" UserMapper
private UserMapper userMapper;

@Mock
private PasswordEncoder passwordEncoder;

@InjectMocks // 自动注入 Mock 到 UserService
private UserServiceImpl userService;

@Test
void should_register_user() {
// Given - 定义 Mock 的行为
when(userMapper.selectByUsername("test")).thenReturn(null);
when(passwordEncoder.encode("123456")).thenReturn("encrypted");

// When
userService.register(createUser("test", "123456"));

// Then - 验证 Mock 被调用
verify(userMapper).insert(any(User.class));
}
}

何时使用

  • ✅ 测试单个类的逻辑
  • ✅ 速度极快(毫秒级)
  • ✅ 不需要数据库
  • ⚠️ 无法测试组件集成

3. @MockitoBean - 部分 Mock

@SpringBootTest
class AuthControllerTest {

@Autowired
private MockMvc mockMvc;

@MockitoBean // Mock 一个 Spring Bean
private IUserService userService; // 其他 Bean 仍然是真实的

@Test
void should_return_token_when_login() throws Exception {
// Given - Mock Service 层的行为
LoginVO loginVO = new LoginVO();
loginVO.setToken("fake-token");
when(userService.login(any())).thenReturn(loginVO);

// When - 测试真实的 Controller
mockMvc.perform(post("/api/auth/login")
.content("{\"username\":\"admin\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").value("fake-token"));
}
}

何时使用

  • ✅ 测试 Controller 但不测试 Service
  • ✅ 需要 Spring 但想避免真实数据库

💡 如何写好测试

遵循 Given-When-Then 模式

@Test
@DisplayName("应该返回 JWT Token - 当登录成功时")
void should_return_jwt_token_when_login_success() {
// ===== Given(准备)=====
// 准备测试数据
LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("admin");
loginDTO.setPassword("password123");

// 准备 Mock 行为
User mockUser = new User();
mockUser.setPassword("$2a$10$encodedPassword");
when(userMapper.selectByUsername("admin")).thenReturn(mockUser);
when(passwordEncoder.matches("password123", mockUser.getPassword())).thenReturn(true);

// ===== When(执行)=====
LoginVO result = authService.login(loginDTO);

// ===== Then(验证)=====
assertNotNull(result);
assertNotNull(result.getToken());
assertTrue(result.getToken().startsWith("eyJ")); // JWT 格式

// 验证调用
verify(userMapper).selectByUsername("admin");
verify(passwordEncoder).matches(anyString(), anyString());
}

测试命名规范

项目规范:should_expectedBehavior_when_state()

// ✅ 好的命名
should_return_user_when_register_success()
should_throw_exception_when_username_exists()
should_return_empty_list_when_no_users()

// ❌ 糟糕的命名
testRegister()
test1()
registerTest()

测试边界条件

@Test
void should_handle_edge_cases() {
// 测试 null
assertThrows(NullPointerException.class, () -> {
userService.register(null);
});

// 测试空字符串
user.setUsername("");
assertThrows(ValidationException.class, () -> {
userService.register(user);
});

// 测试边界值
user.setAge(0);
user.setAge(150);
user.setAge(-1);

// 测试空集合
List<User> emptyList = Collections.emptyList();
}

📚 权威学习资源

官方文档

  1. JUnit 5 官方文档
    https://junit.org/junit5/docs/current/user-guide/
    ⭐⭐⭐⭐⭐ 必读!最权威的 JUnit 5 指南

  2. Spring Boot Testing 官方文档
    https://docs.spring.io/spring-boot/reference/testing/index.html
    ⭐⭐⭐⭐⭐ Spring 测试的完整指南

  3. Mockito 官方文档
    https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
    ⭐⭐⭐⭐⭐ 学习 Mock 的最佳资源

经典书籍

  1. 《测试驱动开发》(Test Driven Development: By Example)
    作者:Kent Beck
    ⭐⭐⭐⭐⭐ TDD 鼻祖,必读经典

  2. 《单元测试的艺术》(The Art of Unit Testing)
    作者:Roy Osherove
    ⭐⭐⭐⭐⭐ 深入浅出的单元测试指南

  3. 《重构:改善既有代码的设计》(Refactoring)
    作者:Martin Fowler
    ⭐⭐⭐⭐⭐ 测试是重构的基础

在线教程

  1. Baeldung - Spring Boot Testing
    https://www.baeldung.com/spring-boot-testing
    实用的 Spring Boot 测试教程

  2. Petri Kainulainen - Spring Test
    https://www.petrikainulainen.net/spring-framework-tutorial/
    深入的 Spring 测试系列文章


🎯 实践建议

从小开始

// 第一步:为核心业务逻辑写测试
@Test
void should_register_user_successfully() {
UserDTO user = new UserDTO();
user.setUsername("testuser");
user.setPassword("password123");

UserVO result = userService.register(user);

assertNotNull(result);
assertEquals("testuser", result.getUsername());
}

逐步提升

  1. 第 1 周:学会写最基本的单元测试
  2. 第 2 周:学会使用 Mockito Mock 依赖
  3. 第 3 周:学会写 Controller 集成测试
  4. 第 4 周:理解测试金字塔,合理分配测试类型

持续改进

  • 每次修复 Bug 后,先写一个失败的测试,然后修复代码
  • 每次添加新功能,先写测试定义行为
  • 定期回顾测试,删除无效测试

❓ 常见问题解答

Q: 我的项目时间紧,来不及写测试怎么办?

A: 这是最大的误区!

不写测试的成本:
❌ 手动测试 1 个功能:5 分钟
❌ 测试 10 次(改 10 次代码):50 分钟
❌ 发现 Bug 后修复 + 回归测试:2 小时

写测试的成本:
✅ 写测试:15 分钟
✅ 运行测试 1000 次:5 秒 × 1000 = 1.4 小时(自动化)
✅ 发现 Bug:立即(测试失败)

结论:写测试其实节省时间

Q: 我应该测试 private 方法吗?

A: 不应该

  • Private 方法是实现细节
  • 测试 public 方法时会自动覆盖 private 方法
  • 如果 private 方法很复杂,考虑提取成独立的类

Q: Mock 和真实调用哪个好?

A: 都需要!

单元测试(70%): 使用 Mock,快速验证逻辑
集成测试(20%): 使用真实调用,验证集成
E2E 测试(10%): 完全真实,验证用户流程

🌟 最后的建议

测试不是为了"应付"覆盖率,而是为了自己
测试是你的安全网,让你敢于重构、敢于优化、敢于尝试

从今天开始:

  1. ✅ 为你最担心的功能写第一个测试
  2. ✅ 每天写 1-2 个测试
  3. ✅ 一个月后,你会感谢现在的自己

记住:好的开发者写代码,伟大的开发者写测试!