前言
在Testing: 解除依賴!測試替身與依賴注入一文中,認識了測試替身與依賴注入,並且知道這兩者的目的為何,但都只知其一,接下來要深入瞭解其他方式與用法。Fake(偽造物件)讓你的程式順利測試,又不會有副作用。
下面這張是關於測試替身的類型,在前一篇內容有出現過,但只提到了Stub這種測試替身,那其他像是Fake、Spy和Mock又是甚麼?首先,先來提Fake!
這種測試替身對我們來說一點也不陌生,開發者即使沒有寫單元測試時,也會用一些假資料,這種假資料有點類似真實資料的簡單版本,這就是Fake,它既不影響到你的程式,也能模擬幾近真實的場景。假設我們有個UserRepository的介面,這個UserRepository在真實情況是透過資料庫連結後,尋找User物件資料,類別圖與程式碼如下。
- User物件
public class User {
private int id;
private String userName;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
}
- UserRepository介面
public interface UserRepository {
void save(User user);
User findById(int id);
User findByUserName(String userName);
}
- DatabaseUserRepository物件
public class DatabaseUserRepository implements UserRepository {
@Override
public void save(User user) {
// 連接資料庫,並且將User存入資料庫中。
}
@Override
public User findById(int id) {
// 如果連接資料庫成功,則從資料庫透過Id尋找該User,找不到則回傳空物件。
return null;
}
@Override
public User findByUserName(String userName) {
// 如果連接資料庫成功,則從資料庫透過userName尋找該User,找不到則回傳空物件。
return null;
}
}
- Client物件
public class Client {
private UserRepository userRepository;
public Client(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void saveUser(User user) {
// 其他商業邏輯
this.userRepository.save(user);
}
public User findById(int id) {
// 其他商業邏輯
return this.userRepository.findById(id);
}
public User findByUserName(String userName) {
// 其他商業邏輯
return this.userRepository.findByUserName(userName);
}
}
User物件就是單純的資料物件;UserRepository介面提供資料的操作,無論你是使用資料庫、網路或檔案系統;DatabaseUserRepository物件則是UserRepository的實作,這裡採用資料庫存取,但細節不實作,僅用註解示意;Clien物件就是一個操作UserRepository的使用端物件,考量現實環境,這邊用註解模擬其他商業邏輯。
從類別圖我們可以看見,用戶端要是真的測試這個場景,就必須用到真實的外部資源,但是依照單元測試的特性,我們就是無法依賴外部資源也要進行測試啊!這時就可以使用Fake物件來模擬這種情況,同時也能進行測試,類別圖與程式碼如下。
- FakeUserRepository物件
public class FakeUserRepository implements UserRepository {
private Collection<User> users = new ArrayList<>();
@Override
public void save(User user) {
if (this.findById(user.getId()) == null) {
this.users.add(user);
}
}
@Override
public User findById(int id) {
for (User user : users) {
if (user.getId() == id) {
return user;
}
}
return null;
}
@Override
public User findByUserName(String userName) {
for (User user : users) {
if (user.getUserName().equals(userName)) {
return user;
}
}
return null;
}
}
- UserRepositoryTest測試類別
public class UserRepositoryTest {
private Client client;
private FakeUserRepository fake;
@Before
public void setUp() throws Exception {
this.fake = new FakeUserRepository();
this.client = new Client(this.fake);
}
@Test
public void testFindByIdIsActuallyFound() throws Exception {
// Arrange
this.addFakeUsers();
// Act
User actualResult = this.client.findById(1);
// Assert
assertThat(actualResult.getId(), equalTo(1));
}
@Test
public void testFindByIdIsNotFound() throws Exception {
// Arrange
this.addFakeUsers();
// Act
User actualResult = this.client.findById(0);
// Assert
assertThat(actualResult, nullValue());
}
@Test
public void testFindByUserNameIsActuallyFound() throws Exception {
// Arrange
this.addFakeUsers();
// Act
User actualResult = this.client.findByUserName("Xavier3");
// Assert
assertThat(actualResult.getUserName(), equalTo("Xavier3"));
}
@Test
public void testFindByUserNameIsNotFound() throws Exception {
// Arrange
this.addFakeUsers();
// Act
User actualResult = this.client.findByUserName("Xavier0");
// Assert
assertThat(actualResult, nullValue());
}
private void addFakeUsers() {
for (int i = 0; i < 5; i++) {
User user = new User();
user.setId(i + 1);
user.setUserName("Xavier" + (i + 1));
this.fake.save(user);
}
}
}
這個FakeUserRepository就是所謂的Fake物件,它模擬了存取資料庫的情況。最後我們成功通過測試啦!如下圖。這時你就可以在你的場景中用它來替換掉緩慢的現實場景,以及望塵莫及的依賴性。
Spy(測試間諜),來偷取秘密吧!
接著來談Spy!先前有提過,對於單元測試的結果斷定,會涉及三種形式,其中一種就是預期被測物件與相依物件的互動,這種往往沒有回傳值可以讓你斷言,舉個例子,以下有兩個方法。
- 有回傳值
public String concat(String first, String second) {
............................
}
對於有回傳值的檢驗方式,直覺就是將兩個String參數傳進去,並且斷言回傳值是什麼就好。這沒什麼問題,畢竟回傳值的確是我們最關心的,那下列的方法該如何測試?
- 沒有回傳值
public void recordMessages(Level level, String message) {
for (ILogger each : loggers) {
each.write(level, message);
}
}
這裡並沒有回傳值供我們斷言,這個方法做的就是一個Logger集合的訊息寫入,要驗證這個方法是否正常工作,唯一的方式就是事後我們檢查列表。就像是你派臥底警察,然後他回報給你所知道的一切。有時這一點無需使用測試替身,因為參數本身就提供測試足夠的訊息,可以讓你獲取額外資訊,但在某些情境中,參數可能資訊不足,我們可以看以下例子。
- ILogger介面
public interface ILogger {
void write(Level level, String message);
}
- LoggerSystem物件
public class LogSystem {
private final ILogger[] loggers;
public LogSystem(ILogger... loggers) {
this.loggers = loggers;
}
public void recordMessages(Level level, String message) {
for (ILogger each : loggers) {
each.write(level, message);
}
}
}
以上的程式碼可以知道,被測的物件是一個LogSystem物件,裡面有著一個記錄不同Logger的陣列。當我們向LogSystem執行recordMessages方法時,應該向所有的Logger物件寫入同樣的訊息。在測試的觀點來看,我們無法知道指定的訊息是否被寫入,然而Logger物件也只有提供一個write方法,資訊顯然也不足讓測試者獲取,這時可以悄悄地讓Spy登場了。
- SpyLogger物件
public class SpyLogger implements ILogger {
private List<String> logMessages = new ArrayList<>();
@Override
public void write(Level level, String message) {
this.logMessages.add(this.concatenated(level, message));
}
public boolean received(Level level, String message) {
return this.logMessages.contains(concatenated(level, message));
}
private String concatenated(Level level, String message) {
return level.getName() + ": " + message;
}
}
- LogSystem測試物件
public class LogSystemTest {
@Test
public void testWriteEachMessageToAllTargets() throws Exception {
// Arrange
SpyLogger spy1 = new SpyLogger();
SpyLogger spy2 = new SpyLogger();
SpyLogger spy3 = new SpyLogger();
SpyLogger spy4 = new SpyLogger();
SpyLogger spy5 = new SpyLogger();
LogSystem logTarget = new LogSystem(spy1, spy2, spy3, spy4, spy5);
// Act
logTarget.recordMessages(Level.INFO, "這些是訊息!");
// Assert
assertThat(spy1.received(Level.INFO, "這些是訊息!"), is(true));
assertThat(spy2.received(Level.INFO, "這些是訊息!"), is(true));
assertThat(spy3.received(Level.INFO, "這些是訊息!"), is(true));
assertThat(spy4.received(Level.INFO, "這些是訊息!"), is(true));
assertThat(spy5.received(Level.INFO, "這些是訊息!"), is(true));
}
}
看到了嗎?這就是測試間諜,如同其他測試替身,你把派出這些間諜,當作參數傳入,然後你讓測試間諜擴充功能紀錄已發送的訊息,並且提供測試鞭打拷問它的方法,是否收到指定的訊息。最後,我們也成功通過測試了!如下圖。
Mock(模擬物件),間諜老大就是你!
其實Mock就是一種全能的測試替身,可以算是Stub與Spy的結合,你既可以指定它所回傳的預設值,同時也可以像Spy那樣擴充它的功能去記錄過去所發生的事情,好提供足夠的資訊讓測試程式知道,如下列程式。
- ICheckInFee介面
public interface ICheckInFee {
int getFee(Customer customer);
double getDiscountedFee(Customer customer);
}
- Customer資料物件
public class Customer {
private boolean isVIP;
public boolean isVIP() {
return isVIP;
}
public void setVIP(boolean VIP) {
isVIP = VIP;
}
}
- BookStore操作物件
public class BookStore {
private ICheckInFee checkInFee;
private double income = 0;
public BookStore(ICheckInFee checkInFee) {
this.checkInFee = checkInFee;
}
public void checkInFee(List<Customer> customers) {
for (Customer customer : customers) {
boolean isVIP = customer.isVIP();
if (isVIP) {
this.income += this.checkInFee.getDiscountedFee(customer);
} else {
this.income += this.checkInFee.getFee(customer);
}
}
}
public double getIncome() {
return income;
}
}
以上是個簡單的書店物件,用來計算VIP和一般顧客的費用,以這個程式的測試觀點,checkInFee方法是不返回任何值的,唯一能獲取income資料的是getIncome方法,然而要測試checkInFee方法是否功能正常,我們則要檢驗BookStore與ICheckInFee間的互動,並且在是否為VIP時,各自呼叫哪個方法,程式碼如下。
- MockCheckInFee物件
public class MockCheckInFee implements ICheckInFee {
public int getFee_called_counter = 0;
public int getDiscountedFee_called_counter = 0;
@Override
public int getFee(Customer customer) {
this.getFee_called_counter++;
return 1000;
}
@Override
public double getDiscountedFee(Customer customer) {
this.getDiscountedFee_called_counter++;
return 1000 * 0.8;
}
}
- BookStoreTest測試物件
public class BookStoreTest {
@Test
public void testCheckInFeeNotVIP() throws Exception {
// Arrange
MockCheckInFee mockCheckInFee = new MockCheckInFee();
BookStore bookStore = new BookStore(mockCheckInFee);
// Act
bookStore.checkInFee(this.getCustomers());
// Assert
assertThat(mockCheckInFee.getFee_called_counter, equalTo(2));
assertThat(mockCheckInFee.getDiscountedFee_called_counter, equalTo(4));
}
@Test
public void testGetIncome() throws Exception {
// Arrange
MockCheckInFee mockCheckInFee = new MockCheckInFee();
BookStore bookStore = new BookStore(mockCheckInFee);
// Act
bookStore.checkInFee(this.getCustomers());
// Assert
assertThat(bookStore.getIncome(), equalTo(5200.0));
}
private List<Customer> getCustomers() {
List<Customer> customers = new ArrayList<>();
Customer customer1 = new Customer();
Customer customer2 = new Customer();
Customer customer3 = new Customer();
Customer customer4 = new Customer();
Customer customer5 = new Customer();
Customer customer6 = new Customer();
customer1.setVIP(true);
customer2.setVIP(false);
customer3.setVIP(false);
customer4.setVIP(true);
customer5.setVIP(true);
customer6.setVIP(true);
customers.add(customer1);
customers.add(customer2);
customers.add(customer3);
customers.add(customer4);
customers.add(customer5);
customers.add(customer6);
return customers;
}
}
我們讓MockCheckInFee除了擔任Stub所需要的角色外,我們同時還多了getFee_called_counter與getDiscountedFee_called_counter這兩個成員變數記錄各自方法被呼叫了幾次,提供足夠的資訊讓測試程式了解,這就是Mock強大的功能囉!最後,我們也完成這次的測試,如下圖。
小結
至目前為止,我們討論完剩下的測試替身了,它們是開發者的測試工具,也沒有硬性規定只能使用哪一種,甚至混合使用或身兼多職都是可以的。當然,我們還是有些啟發式準則能知道這些測試替身出現的合適場景。
- 如果你關心的是測試物件與其他物件的交互,而且你著重協作者的方法調用,你就有可能需要一個Mock物件。
- 如果你的環境很複雜,你又想執行這個複雜的環境,那可以考慮使用Fake物件。
- 如果你想使用Mock,但又想精簡它,可能可以考慮簡化成Spy物件。
- 如果你的被測物件只關心合作物件帶給你的值,可以使用Stub物件就好。
還是覺得太難記?這裡有些好記的方式,Stub/Fake和Spy/Mock物件差異在於,我們不會對前者斷言,卻會對後者斷言;Stub管的是值的查詢,Mock則是管方法操作。持續練習單元測試吧!
沒有留言:
張貼留言