测试基础:从零开始理解软件测试
🤔 为什么要写测试?
真实场景:没有测试的痛苦
想象一下这个场景:
// 你写了一个用户注册功能
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%+ | 这是你的价值所在,必须测试 |
| Controller | 60%+ | 主要测试参数验证和错误处理 |
| 工具类 (Utils) | 90%+ | 无状态、易测试,应该全覆盖 |
| Entity/DTO | 0% | 数据类,无需测试 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();
}
📚 权威学习资源
官方文档
-
JUnit 5 官方文档
https://junit.org/junit5/docs/current/user-guide/
⭐⭐⭐⭐⭐ 必读!最权威的 JUnit 5 指南 -
Spring Boot Testing 官方文档
https://docs.spring.io/spring-boot/reference/testing/index.html
⭐⭐⭐⭐⭐ Spring 测试的完整指南 -
Mockito 官方文档
https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
⭐⭐⭐⭐⭐ 学习 Mock 的最佳资源
经典书籍
-
《测试驱动开发》(Test Driven Development: By Example)
作者:Kent Beck
⭐⭐⭐⭐⭐ TDD 鼻祖,必读经典 -
《单元测试的艺术》(The Art of Unit Testing)
作者:Roy Osherove
⭐⭐⭐⭐⭐ 深入浅出的单元测试指南 -
《重构:改善既有代码的设计》(Refactoring)
作者:Martin Fowler
⭐⭐⭐⭐⭐ 测试是重构的基础
在线教程
-
Baeldung - Spring Boot Testing
https://www.baeldung.com/spring-boot-testing
实用的 Spring Boot 测试教程 -
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 周:学会写最基本的单元测试
- 第 2 周:学会使用 Mockito Mock 依赖
- 第 3 周:学会写 Controller 集成测试
- 第 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 个测试
- ✅ 一个月后,你会感谢现在的自己
记住:好的开发者写代码,伟大的开发者写测试!