為什么你的測試應該只驗證可觀察的行為,而不是實現細節
在本文中,我們將考慮我們的測試到底應該(不)驗證什么以防止誤報,以及為什么有時越少越好。為了更好地理解這個主題,我們將仔細研究脆性測試和可觀察行為的定義,以便我們能夠檢測設計不良的測試并使其抵抗重構。
讓我們開始吧!
當您的測試想知道太多時
回到過去,在我深入研究自動化測試這個主題之前,它已經發生在我身上很多次了。究竟是什么?好吧,以防萬一,我想確保我的測試驗證了比必要的更多的東西。我曾經相信我的測試包含的斷言和類似的陳述越多,它們帶來的價值就越大。
雖然上述方法看起來很合理,但從長遠來看,選擇它會讓開發人員的生活變得困難。當我自己的測試讓我不得不比我預期的更頻繁地回到他們身邊時,我很難發現這一點。一個理由?事實證明,這些測試與實現細節有關,而不是可觀察到的行為,因此,在重構時,即使功能仍然可以正常工作,它們也會失敗。
脆弱的測試? 可觀察的行為? 實施細則?
在我們進一步討論之前,讓我們先定義一下這些神秘短語背后的含義,因為它們對于理解如何編寫為我們的項目增加真正價值而不是不必要的包袱的良好測試至關重要。
- 它們無法承受重構,無論底層功能是否損壞,它們都會變紅
換句話說,重構后的功能仍然可以產生正確的結果,但與此同時,如果他們檢查某些東西是如何工作的,而不是檢查可觀察到的行為是什么,你的測試可能會失敗。
- 要使一段代碼成為系統可觀察行為的一部分,它必須執行以下操作之一:
- 公開幫助客戶實現其目標之一的操作。 操作是一種執行計算或產生副作用或兩者兼而有之的方法。
- 暴露一種可以幫助客戶實現其目標之一的狀態。 狀態是系統的當前狀態。
- 任何不做這兩件事的代碼都是實現細節。
因此,當您在開發新功能時,請考慮調用您的代碼的客戶端的真正目標是什么(客戶端代碼期望從我們的解決方案中獲得什么行為,或者您的功能應該涵蓋哪些業務案例),然后忘記 暫時您想如何開發該功能(實現細節)。
這種方法應該讓您在可觀察的行為和實現細節之間有一個更清晰的區別。
案例研究:排行榜
讓我們仔細看看下面用 JAVA 編寫的示例:
- 我們正在為一款游戲開發排行榜,我們稱這款游戲為“Chase and Race”
- 我們希望我們的排行榜根據得分返回最佳玩家
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 行刪除斷言,因為它檢查實現細節,這使得該測試變得脆弱。
結論
在項目中進行脆弱測試的后果可能非常嚴重。 例如,這樣的測試可能會阻止開發人員重構代碼,因為老實說——當你完成重構時,這導致了一堆失敗的測試,這并不一定會讓你心情愉快。 另一個可能的后果是,開發人員可以習慣于發出錯誤警報的測試,從而降低他們的整體警覺性,從而使錯誤潛入生產環境的機會增加。






