上一講提過單元測試的 Side Effect,也聊到解決方式是取代外部依賴,或是模擬外部環境。
那麼取代和模擬有什麼差別呢?在什麼情況下適合取代、又在什麼情況下適合模擬呢?
假設我們有一個 Function,向外部的資料庫取得使用者的 First Name 及 Last Name,然後組合起來回傳。
async function getFormattedName(userId, db) {
const user = await db.getUserById(userId);
return `${user.firstName}, ${user.lastName}`;
}
*Function 讀取資料示意圖
在我們實作單元測試時,顯然不適合直接啟動一個資料庫來真的互動。當有資料庫這個外部環境的 Side Effect 時,我們就可以選用取代或模擬這兩招來隔絕依賴。
我們稱 Stub 為取代,不論我們對外部目標送出的參數為何,所謂 Stub 這個結果,就意味著取代了原本資料庫回傳的內容。
而 Mock 可以稱作模擬,對比 Stub 又多了一層互動(Interaction)的意味,因為我們在 Mock 的過程中不單單是關心回傳的數值,更關心互動的內容:例如我對資料庫送的 Query 是否成功了?這個 Query 總共被呼叫幾次等等。
*Stub vs Mock
因此簡單來說,當我們只關心外部資料的回傳值時,就 Stub 此資料,如果在乎和外部環境的互動行為時,就 Mock 整個外部環境。
上述的例子中,我們的 Function 其實做的主要事情是將得到的 Frist Name 和 Last Name 組合起來,相較於對資料庫的 Query 是否成功之類的行為其實並不在意。
所以我們將資料庫回傳的內容直接 Stub 起來就很合適了。
test('Should return correct formatted user name', async () => {
const dbStub = { getUserById: async (userId) => ({ first: 'John', last: 'Doe' }) }
const formattedName = await getFormattedName(1, dbStub);
expect(formattedName).toBe('John, Doe')
})
這邊寫的 dbStub
做的事情就是將 getUserById()
這個與資料庫互動的 Function 給 Stub 起來,因為我們知道在 getFormattedName()
當中會呼叫 db.getUserById()
。不論輸入的 userId
這個 Input 為何,我們都一率回傳 { first: 'John', last: 'Doe' }
這個物件。
和資料處理相關的 getFormattedName()
可以用 Stub 來代替資料庫回傳資料,而裡面所呼叫的 getUserById()
卻是直接和資料庫互動的 Function。
假如 getUserById()
是我們自己寫的,用了 msyql
的 library 建立一個 connection
,並直接下 raw SQL 來取得資料:
const getUserById = async (userId) => {
const [rows] = await connection.execute('SELECT * FROM Users WHERE id = ?', [userId]);
if (rows.length === 0) throw new Error('User not found');
return rows[0];
}
當我們對自己寫的 getUserById()
使用 Stub 的方式來測試時,就沒有這麼大的意義了,因為我們只是 By Pass 對資料庫送出的 Query 及其參數,然後 By Pass 資料庫回傳的資料。
這個時候,我們更在乎和資料庫的互動,像是在不同條件下,資料庫會給我們不同的回覆。舉例來說,當 userId
不存在的時候,我們得到的 rows
就會是一個長度為 0 的 Array,然而有找到 User 時,rows
裡面就會有內容。
下面是一個模擬找不到 User 的測試範例:
test('Should throw error if user not found', async () => {
connection.execute.mockResolvedValue([[]]);
await expect(getUserById(999)).rejects.toThrow('User not found');
expect(connection.execute).toHaveBeenCalledWith('SELECT * FROM Users WHERE id = ?', [999]);
});
我們先 Mock 資料庫因為找不到 ID 為 999
的 User 而回傳空的 Array,此時就可以預期我們寫的 getUserById()
能夠拋出 User not found
的錯誤。
以及我們關心的 SQL Query 是否真的有被這個 Function 所送出,所以透過 Mock 的這種方式,我們可以確認 connection.execute()
是否真的有被呼叫,以及送出的參數(也就是 raw SQL)是否是我們所預期的。