我们都经历过这种情况。你打开一个测试文件,期待能理解代码的功能,结果却看到一个让你质疑一切的怪物。单元测试本应让我们的生活更轻松——它们记录行为、捕捉回归问题,并让我们有信心进行重构。但有时候,它们却变成了它们本该防止的东西:无法维护的噩梦。
让我分享一些我在实际工作中遇到的最丑陋的单元测试。名字已经改变以保护有罪者,但恐怖是真实的。
"一次测试所有东西"怪物
@Test
public void testUserService() {
// 测试用户创建
User user = new User("john", "password123");
userService.save(user);
assertNotNull(user.getId());
// 测试用户登录
boolean loggedIn = userService.login("john", "password123");
assertTrue(loggedIn);
// 测试用户更新
user.setEmail("john@example.com");
userService.update(user);
assertEquals("john@example.com", userService.findById(user.getId()).getEmail());
// 测试用户删除
userService.delete(user.getId());
assertNull(userService.findById(user.getId()));
// 测试密码重置
User user2 = new User("jane", "password456");
userService.save(user2);
userService.resetPassword(user2.getId(), "newpassword");
assertTrue(userService.login("jane", "newpassword"));
}
🔥 问题所在
这个测试违反了基本原则:一个测试,一个关注点。当这个测试失败时,五种不同行为中的哪一个坏了?你需要调试整个方法才能找出答案。测试变得相互依赖——如果用户创建失败,其他所有东西也会失败,隐藏了其他潜在的错误。
应该怎么做:五个独立的测试,每个都有清楚的名称描述它验证什么。当 testUserDeletion 失败时,你确切知道该看哪里。
"睡眠并祈祷"方法
def test_async_processing():
job_id = queue.submit_job(data)
time.sleep(5) # 等待工作完成
result = queue.get_result(job_id)
assert result.status == "completed"
⏰ 问题所在
基于时间的测试是不稳定的噩梦。在快速的机器上,5 秒可能足够。在负载下的慢速 CI 服务器上,可能不够。测试在本地通过但在生产环境中随机失败。开发人员开始忽略测试失败,因为"又是那个不稳定的测试"。
应该怎么做:使用适当的同步机制——回调、promise 或带超时的轮询。如果可能的话,模拟异步行为。永远不要依赖任意的睡眠时间。
“复制粘贴天堂”
test('user can add item to cart', () => {
const user = { id: 1, name: 'John', email: 'john@test.com', address: '123 Main St', phone: '555-1234' };
const cart = { id: 1, userId: 1, items: [], total: 0, tax: 0, shipping: 0 };
const item = { id: 101, name: 'Widget', price: 29.99, quantity: 1, category: 'tools' };
addToCart(user, cart, item);
expect(cart.items.length).toBe(1);
});
test('user can remove item from cart', () => {
const user = { id: 1, name: 'John', email: 'john@test.com', address: '123 Main St', phone: '555-1234' };
const cart = { id: 1, userId: 1, items: [{ id: 101, name: 'Widget', price: 29.99, quantity: 1, category: 'tools' }], total: 29.99, tax: 2.50, shipping: 5.00 };
const item = { id: 101, name: 'Widget', price: 29.99, quantity: 1, category: 'tools' };
removeFromCart(user, cart, item);
expect(cart.items.length).toBe(0);
});
test('user can update item quantity', () => {
const user = { id: 1, name: 'John', email: 'john@test.com', address: '123 Main St', phone: '555-1234' };
const cart = { id: 1, userId: 1, items: [{ id: 101, name: 'Widget', price: 29.99, quantity: 1, category: 'tools' }], total: 29.99, tax: 2.50, shipping: 5.00 };
const item = { id: 101, name: 'Widget', price: 29.99, quantity: 2, category: 'tools' };
updateCartItem(user, cart, item);
expect(cart.items[0].quantity).toBe(2);
});
📋 问题所在
大量重复使维护成为噩梦。需要改变用户对象结构?在 50 个地方更新它。设置代码比实际测试逻辑还长,将重要部分埋在噪音中。
应该怎么做:提取测试固件,使用工厂函数,或利用测试设置方法。测试应该专注于使其独特的东西,而不是重复样板代码。
“魔术数字盛宴”
[Test]
public void TestOrderCalculation()
{
var order = new Order();
order.AddItem(100, 2);
order.AddItem(50, 3);
order.ApplyDiscount(0.1);
Assert.AreEqual(315, order.GetTotal());
}
❓ 问题所在
这些数字是什么意思?为什么 315 是预期结果?折扣是 10% 还是 0.1%?当这个测试失败时,你会花 10 分钟用计算器算数学,然后才能开始调试。
应该怎么做:使用命名常量或变量来解释计算。const decimal ITEM_PRICE = 100m; const int QUANTITY = 2; const decimal DISCOUNT_PERCENT = 10m;
现在测试自己记录自己。
"测试框架"杰作
@Test
public void testListAdd() {
List<String> list = new ArrayList<>();
list.add("test");
assertEquals(1, list.size());
assertEquals("test", list.get(0));
}
@Test
public void testMapPut() {
Map<String, Integer> map = new HashMap<>();
map.put("key", 42);
assertEquals(42, map.get("key"));
}
🤦 问题所在
这些测试验证 Java 的标准库是否正常工作。剧透:它确实正常。Oracle 已经广泛测试了 ArrayList 和 HashMap。这些测试增加零价值,同时增加维护负担和构建时间。
应该怎么做:测试你的代码,而不是框架。如果你没有添加任何业务逻辑,你不需要测试。
"注释驱动开发"方法
def test_user_registration():
# 创建用户
user = User()
# 设置用户名
user.username = "testuser"
# 设置密码
user.password = "password123"
# 设置电子邮件
user.email = "test@example.com"
# 保存用户
db.save(user)
# 检索用户
saved_user = db.get_user("testuser")
# 检查用户是否存在
assert saved_user is not None
# 检查用户名是否匹配
assert saved_user.username == "testuser"
# 检查电子邮件是否匹配
assert saved_user.email == "test@example.com"
💬 问题所在
只是重复代码所做的事情的注释是噪音。它们不增加清晰度——它们增加混乱。如果你的测试需要这么多注释才能理解,测试本身写得很差。
应该怎么做:编写具有清楚变量名称和结构的自我记录代码。使用测试名称来描述正在测试什么。注释应该解释为什么,而不是什么。
"什么都不断言"信心增强器
test('process payment', async () => {
const payment = { amount: 100, currency: 'USD' };
await paymentService.process(payment);
// 测试通过!
});
✅ 问题所在
这个测试总是通过,因为它没有断言任何东西。这是一种虚假的安全感。付款可能失败、抛出内部捕获的异常或返回错误——测试仍然是绿色的。
应该怎么做:断言预期结果。付款成功了吗?数据库更新了吗?用户收到确认了吗?没有断言的测试不是测试。
"模拟所有东西"模拟器
@Test
public void testUserService() {
UserRepository mockRepo = mock(UserRepository.class);
EmailService mockEmail = mock(EmailService.class);
Logger mockLogger = mock(Logger.class);
Config mockConfig = mock(Config.class);
TimeProvider mockTime = mock(TimeProvider.class);
when(mockRepo.findById(1)).thenReturn(new User("john"));
when(mockConfig.get("feature.enabled")).thenReturn("true");
when(mockTime.now()).thenReturn(Instant.parse("2025-01-01T00:00:00Z"));
UserService service = new UserService(mockRepo, mockEmail, mockLogger, mockConfig, mockTime);
User user = service.getUser(1);
assertEquals("john", user.getName());
verify(mockLogger).info("User retrieved: john");
}
🎭 问题所在
你正在测试模拟返回你告诉它们返回的东西。这个测试对实际业务逻辑没有验证任何东西。它与现实如此隔离,以至于在生产代码完全损坏时它可能通过。
应该怎么做:模拟外部依赖(数据库、API、文件系统),但不要模拟所有东西。尽可能用真实对象测试真实逻辑。集成测试补充单元测试——两者都使用。
"忽略失败"策略
@pytest.mark.skip(reason="Flaky test, will fix later")
def test_concurrent_access():
# 测试实现
pass
@unittest.skip("Fails on CI, works locally")
def test_file_upload():
# 测试实现
pass
🚫 问题所在
跳过的测试是永远不会偿还的技术债务。"稍后修复"变成"永远不修复"。这些测试腐烂,随着时间的推移变得更过时、更难修复。最终,没有人记得为什么它们被跳过或它们应该测试什么。
应该怎么做:修复测试或删除它。如果它真的不稳定,使其确定性。如果它测试的东西不再重要,删除它。跳过的测试比没有测试更糟——它们给予虚假的信心。
教训
使这些测试丑陋的不仅仅是糟糕的风格——而是它们在测试的基本目的上失败了:提供代码正确工作的信心和它应该如何行为的文档。
好的测试有共同的特征:
专注:一个测试,一个行为。当它失败时,你确切知道什么坏了。
可读:测试名称和结构清楚地传达正在测试什么以及为什么。
确定性:相同的输入,相同的输出,每次都是。没有不稳定性,没有随机性,没有时间依赖性。
快速:测试应该在毫秒内运行,而不是秒。慢速测试不会被运行。
独立:测试不依赖彼此或共享状态。它们可以以任何顺序运行。
可维护:当需求改变时,测试易于更新。重复最小化。
前进之路
如果你在这些例子中认出自己的代码,不要感到难过——我们都写过丑陋的测试。重要的是学习和改进。
当你写下一个测试时,问问自己:
- 如果这个测试在六个月后失败,我会理解为什么吗?
- 我是在测试我的代码还是框架?
- 我能删除一半的设置代码并仍然有一个有效的测试吗?
- 这个测试给我信心代码能工作吗?
单元测试是一项随着实践而提高的技能。我们今天写的丑陋测试教会我们明天写更好的测试。与你的团队分享你的测试恐怖故事。嘲笑它们。从中学习。最重要的是,重构它们。
因为唯一比丑陋测试更糟的是根本没有测试。
✨ 一线希望
每个丑陋的测试都是学习的机会。代码审查捕捉这些问题。重构改进它们。分享这些故事帮助整个社区写更好的测试。我们都在一起。