0%

Unit Test 中的替身 - Dummy 、Stub、Spy、Mock、Fake

在單元測試中,常常需要使用替身(test doubles)來代替真實的物件,以便控制測試的環境和驗證功能。常見的替身有 Dummy、Stub、Spy、Mock、Fake 等,它們的主要區別如下:

  1. Dummy(虛設物件):一個不會被使用的佔位物件,只是用來滿足方法簽名,並不會影響測試結果。
  2. Stub(存根物件):提供固定的、預先定義好的回傳值,讓測試方法可以正常執行,通常用來模擬外部服務、資料存取等操作。
  3. Spy(間諜物件):一個真實的物件,用來監聽被測試物件的方法呼叫和屬性變更,以便在測試中驗證被測試物件的行為是否符合預期。
  4. Mock(模擬物件):與 Spy 類似,也是一個真實的物件,但主要是用來預先定義被測試物件的方法呼叫及預期回傳值,以便在測試中驗證被測試物件的行為是否符合預期。
  5. Fake(假物件):提供一個簡化的實作,讓測試可以在更簡單的環境中運行,通常是用來測試耗時的、昂貴的操作,如資料庫操作、網路請求等。

Dummy(虛設物件) 的範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Database {
void save(String data);
}

public class DummyDatabase implements Database {
public void save(String data) {
// do nothing
}
}

public class Example {
private final Database database;

public Example(Database database) {
this.database = database;
}

public void saveData(String data) {
database.save(data);
}
}

在這個例子中,我們有一個介面 Database 和一個實現該介面的虛設物件 DummyDatabaseDummyDatabase 實際上不做任何事情,因為它只是一個虛設物件,用於測試其他方法的行為是否正確,而不是測試 Database 介面本身的行為。在 Example 類別中,我們需要一個 Database 物件,並且將其作為建構子參數傳遞。在 saveData 方法中,我們調用傳入的 Database 物件的 save 方法,這裡的 database.save(data) 就是在執行我們要測試的功能。

在單元測試中,我們可以使用 DummyDatabase 來代替真正的 Database 物件,以便測試 Example 類別中的其他方法的行為是否正確。

Stub(存根物件)的範例

假設我們有一個介面 Calculator,它定義了一些數學運算的方法:

1
2
3
4
5
6
public interface Calculator {
int add(int x, int y);
int subtract(int x, int y);
int multiply(int x, int y);
int divide(int x, int y);
}

現在,我們要測試一個 CalculatorService 類別,它使用 Calculator 介面來進行數學運算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CalculatorService {
private Calculator calculator;

public CalculatorService(Calculator calculator) {
this.calculator = calculator;
}

public int calculate(int x, int y) {
int sum = calculator.add(x, y);
int difference = calculator.subtract(x, y);
int product = calculator.multiply(x, y);
int quotient = calculator.divide(x, y);

int result = sum + difference + product + quotient;
return result;
}
}

為了測試 CalculatorService 類別的 calculate 方法,我們需要一個假的 Calculator 物件,讓它回傳我們預期的結果。這時候,我們就可以使用 Stub 物件來達成目的。以下是一個可能的實作方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CalculatorStub implements Calculator {
private int expectedSum;
private int expectedDifference;
private int expectedProduct;
private int expectedQuotient;

public CalculatorStub(int expectedSum, int expectedDifference, int expectedProduct, int expectedQuotient) {
this.expectedSum = expectedSum;
this.expectedDifference = expectedDifference;
this.expectedProduct = expectedProduct;
this.expectedQuotient = expectedQuotient;
}

@Override
public int add(int x, int y) {
return expectedSum;
}

@Override
public int subtract(int x, int y) {
return expectedDifference;
}

@Override
public int multiply(int x, int y) {
return expectedProduct;
}

@Override
public int divide(int x, int y) {
return expectedQuotient;
}
}

在這個例子中,CalculatorStub 是一個實作了 Calculator 介面的類別,它會回傳我們預期的數值,而不是實際進行數學運算。透過這個 Stub 物件,我們可以控制測試時的輸入和輸出,以驗證 CalculatorService 的行為是否正確。

Spy(間諜物件)的範例

假設有一個名為 Calculator的類別,其中有一個方法 add可以對兩個數字進行加法運算。現在我們要建立一個 Spy 物件來追蹤 add方法被調用的次數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}

public class CalculatorSpy extends Calculator {
private int addCount = 0;

@Override
public int add(int a, int b) {
addCount++;
return super.add(a, b);
}

public int getAddCount() {
return addCount;
}
}

在這個例子中,我們建立了一個名為 CalculatorSpy的子類別,繼承自 CalculatorCalculatorSpy中新增了一個 addCount變數,用於紀錄 add方法被調用的次數,以及一個 getAddCount方法,用於獲取 addCount的值。

CalculatorSpyadd方法中,我們先將 addCount加1,然後再調用父類別 Calculatoradd方法,實現了對 add方法的追蹤。當我們需要測試 add方法是否正確被調用時,可以使用 CalculatorSpy物件來取得 addCount的值,判斷 add方法是否被正確呼叫。

Mock(模擬物件) 的範例

假設有一個 UserService 介面定義了一個 addUser 方法,接收一個 User 物件作為參數,並回傳一個 boolean 表示是否成功新增使用者。現在我們要實作一個 UserManager 類別,裡面使用了 UserService 來新增使用者,如果新增失敗則會丟出一個例外。我們希望在測試 UserManager類別時,可以模擬 UserService 的行為,測試 UserManager 在不同的情況下的表現。

下面是使用 Mockito 模擬 UserService 物件的範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;

public class UserManagerTest {
@Mock
UserService userService;

private UserManager userManager;

@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
userManager = new UserManager(userService);
}

@Test
public void testAddUserSuccess() throws Exception {
// 模擬 addUser 方法成功回傳 true
when(userService.addUser(any(User.class))).thenReturn(true);

// 執行測試
boolean result = userManager.addUser(new User("test_user"));
assertTrue(result);
}

@Test(expected = UserExistsException.class)
public void testAddUserFailure() throws Exception {
// 模擬 addUser 方法拋出 UserExistsException 例外
when(userService.addUser(any(User.class))).thenThrow(new UserExistsException());

// 執行測試
userManager.addUser(new User("test_user"));
}
}

在這個範例中,我們使用了 @Mock 註解來創建一個 UserService 的模擬物件,並在測試方法中使用 when() 方法來指定模擬物件的行為。在 testAddUserSuccess()測試方法中,我們模擬 addUser方法成功回傳 true,測試 userManager.addUser(new User("test\_user")) 方法的回傳值是否為 true。在 testAddUserFailure() 測試方法中,我們模擬 addUser方法拋出 UserExistsException例外,測試 userManager.addUser(new User("test\_user"))是否會拋出 UserExistsException 例外。

Fake(假物件)的範例

Java中的Fake物件通常是指一個實現了相同介面但是行為不同的假物件,用來在測試中替換真實的物件進行測試。以下是一個簡單的Java Fake物件的範例:

假設我們有一個 UserDao 類別,其中有一個 createUser 方法會新增使用者到資料庫中,現在我們希望在測試時不要真的操作資料庫,可以使用 Fake 物件來模擬資料庫操作。

首先,我們可以建立一個 FakeUserDao 類別,它實作了 UserDao 介面,但是在 createUser 方法中並不會真的寫入資料庫,而是將使用者資訊存入一個 List 中:

1
2
3
4
5
6
7
8
9
10
public class FakeUserDao implements UserDao {
private List<User> users = new ArrayList<>();

@Override
public void createUser(User user) {
users.add(user);
}

// 其他 UserDao 的方法也要實作,但在這個例子中暫時省略
}

接下來我們可以撰寫一個測試,來測試當呼叫 UserService.createUser 時,是否有將使用者資訊儲存到 FakeUserDaousers 屬性中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class UserServiceTest {
@Test
public void testCreateUser() {
// 建立一個 FakeUserDao 物件
UserDao fakeDao = new FakeUserDao();

// 建立一個 UserService 物件,並將 FakeUserDao 物件注入
UserService userService = new UserServiceImpl(fakeDao);

// 呼叫 createUser 方法,傳入一個使用者物件
User user = new User("john.doe", "password");
userService.createUser(user);

// 確認使用者資訊已經被儲存到 FakeUserDao 的 users 屬性中
List<User> users = ((FakeUserDao) fakeDao).getUsers();
assertEquals(1, users.size());
assertEquals("john.doe", users.get(0).getUsername());
assertEquals("password", users.get(0).getPassword());
}
}

在這個測試中,我們先建立了一個 FakeUserDao 物件,然後將它注入到 UserServiceImpl 物件中。接著呼叫 createUser 方法新增一個使用者,最後再確認使用者資訊已經被儲存到 FakeUserDaousers 屬性中。

這樣做的好處是我們可以不用實際操作資料庫就能夠進行測試,這樣可以讓測試更快速、更穩定。