为什么你的测试应该只验证可观察的行为,而不是实现细节
在本文中,我们将考虑我们的测试到底应该(不)验证什么以防止误报,以及为什么有时越少越好。为了更好地理解这个主题,我们将仔细研究脆性测试和可观察行为的定义,以便我们能够检测设计不良的测试并使其抵抗重构。
让我们开始吧!
当您的测试想知道太多时
回到过去,在我深入研究自动化测试这个主题之前,它已经发生在我身上很多次了。究竟是什么?好吧,以防万一,我想确保我的测试验证了比必要的更多的东西。我曾经相信我的测试包含的断言和类似的陈述越多,它们带来的价值就越大。
虽然上述方法看起来很合理,但从长远来看,选择它会让开发人员的生活变得困难。当我自己的测试让我不得不比我预期的更频繁地回到他们身边时,我很难发现这一点。一个理由?事实证明,这些测试与实现细节有关,而不是可观察到的行为,因此,在重构时,即使功能仍然可以正常工作,它们也会失败。
脆弱的测试? 可观察的行为? 实施细则?
在我们进一步讨论之前,让我们先定义一下这些神秘短语背后的含义,因为它们对于理解如何编写为我们的项目增加真正价值而不是不必要的包袱的良好测试至关重要。
换句话说,重构后的功能仍然可以产生正确的结果,但与此同时,如果他们检查某些东西是如何工作的,而不是检查可观察到的行为是什么,你的测试可能会失败。
因此,当您在开发新功能时,请考虑调用您的代码的客户端的真正目标是什么(客户端代码期望从我们的解决方案中获得什么行为,或者您的功能应该涵盖哪些业务案例),然后忘记 暂时您想如何开发该功能(实现细节)。
这种方法应该让您在可观察的行为和实现细节之间有一个更清晰的区别。
案例研究:排行榜
让我们仔细看看下面用 JAVA 编写的示例:
Player 类负责保存玩家的姓名和分数。 分数通过 Player#updateScore 函数更新。
Leaderboard 类允许我们通过 Leaderboard#addPlayer 函数将玩家添加到排行榜的列表中,并通过 Leaderboard#getBestPlayer 检索游戏中最好的玩家。
在 LeaderboardTesttest 类中,我们正在检查 Leaderboard#getBestPlayer 方法是否能够返回得分最高的玩家:
package chaseandrace.player;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Leaderboard {
List<Player> players;
public Leaderboard() {
players = new ArrayList<>();
}
public void addPlayer(Player player) {
this.players.add(player);
}
public Player getBestPlayer() {
return players.stream()
.max(Comparator.comparing(Player::getScore))
.orElse(null);
}
}
package chaseandrace.player;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
public class LeaderboardTest {
@Test
void getPlayerWithHighestScore() {
var playerOne = new Player("I don't know what I'm doing here");
var playerTwo = new Player("Chase me");
var playerThree = new Player("Okie Dokie");
playerOne.updateScore(50);
playerTwo.updateScore(90);
playerThree.updateScore(85);
var board = new Leaderboard();
board.addPlayer(playerOne);
board.addPlayer(playerTwo);
board.addPlayer(playerThree);
var bestPlayer = board.getBestPlayer();
assertEquals(playerTwo, bestPlayer);
assertEquals(bestPlayer, board.players.get(1));
}
}
package chaseandrace.player;
public class Player {
private String name;
private int score;
public Player(String name) {
this.name = name;
}
public int getScore() {
return score;
}
public void updateScore(int points) {
score += points;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
到目前为止,一切都很好——如您所见,测试报告是绿色的。
后来,我们决定重构 Leaderboard 类的内部结构,因此每当我们向其中添加新玩家时,它都会按降序对玩家列表进行排序:
package chaseandrace.player;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Leaderboard {
List<Player> players;
public Leaderboard() {
players = new ArrayList<>();
}
public void addPlayer(Player player) {
this.players.add(player);
this.players.sort(Comparator.comparing(Player::getScore, Comparator.reverseorder()));
}
public Player getBestPlayer() {
if (players.isEmpty()) {
return null;
}
return players.get(0);
}
}
package chaseandrace.player;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
public class LeaderboardTest {
@Test
void getPlayerWithHighestScore() {
var playerOne = new Player("I don't know what I'm doing here");
var playerTwo = new Player("Chase me");
var playerThree = new Player("Okie Dokie");
playerOne.updateScore(50);
playerTwo.updateScore(90);
playerThree.updateScore(85);
var board = new Leaderboard();
board.addPlayer(playerOne);
board.addPlayer(playerTwo);
board.addPlayer(playerThree);
var bestPlayer = board.getBestPlayer();
assertEquals(playerTwo, bestPlayer);
assertEquals(bestPlayer, board.players.get(1));
}
}
package chaseandrace.player;
public class Player {
private String name;
private int score;
public Player(String name) {
this.name = name;
}
public int getScore() {
return score;
}
public void updateScore(int points) {
score += points;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
}
让我们省略关于是否需要此更改的讨论——我想向您展示的是更改实现细节如何影响现有测试。
如您所见,测试报告变为红色,但可观察到的行为保持不变——Leaderboard#getBestPlayer 函数仍然正常工作,它返回得分最高的玩家。
如何解决这个问题? 如果在重构代码库的情况下,一次编写的测试不需要我们额外关注,那将是最好的。 为此,Leaderboard#players 列表应该无法从外部访问,因此使用 private 修饰符标记这个集合就足够了:
package chaseandrace.player;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Leaderboard {
private List<Player> players;
public Leaderboard() {
players = new ArrayList<>();
}
public void addPlayer(Player player) {
this.players.add(player);
this.players.sort(Comparator.comparing(Player::getScore, Comparator.reverseOrder()));
}
public Player getBestPlayer() {
if (players.isEmpty()) {
return null;
}
return players.get(0);
}
}
但是测试呢? 它现在有一个编译错误:
由于您的测试应该只验证可观察的行为,我们可以安全地从第 25 行删除断言,因为它检查实现细节,这使得该测试变得脆弱。
结论
在项目中进行脆弱测试的后果可能非常严重。 例如,这样的测试可能会阻止开发人员重构代码,因为老实说——当你完成重构时,这导致了一堆失败的测试,这并不一定会让你心情愉快。 另一个可能的后果是,开发人员可以习惯于发出错误警报的测试,从而降低他们的整体警觉性,从而使错误潜入生产环境的机会增加。