2016年12月6日 星期二

Testing: 再論測試替身

前言

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則是管方法操作。持續練習單元測試吧!

Source Code

Github: https://github.com/xavier0507/UnitTestSample.git

沒有留言:

張貼留言