iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0
Software Development

全端實戰心法:小團隊的產品開發大小事系列 第 20

單元測試(二):應對 Side Effect 的 Stub 及 Mock

  • 分享至 

  • xImage
  •  

上一講提過單元測試的 Side Effect,也聊到解決方式是取代外部依賴,或是模擬外部環境

那麼取代和模擬有什麼差別呢?在什麼情況下適合取代、又在什麼情況下適合模擬呢?

取代及模擬:Stub and Mock

假設我們有一個 Function,向外部的資料庫取得使用者的 First Name 及 Last Name,然後組合起來回傳。

async function getFormattedName(userId, db) {
  const user = await db.getUserById(userId);
  return `${user.firstName}, ${user.lastName}`;
}

Function 讀取資料示意圖
*Function 讀取資料示意圖

在我們實作單元測試時,顯然不適合直接啟動一個資料庫來真的互動。當有資料庫這個外部環境的 Side Effect 時,我們就可以選用取代模擬這兩招來隔絕依賴。

我們稱 Stub 為取代,不論我們對外部目標送出的參數為何,所謂 Stub 這個結果,就意味著取代了原本資料庫回傳的內容。

而 Mock 可以稱作模擬,對比 Stub 又多了一層互動(Interaction)的意味,因為我們在 Mock 的過程中不單單是關心回傳的數值,更關心互動的內容:例如我對資料庫送的 Query 是否成功了?這個 Query 總共被呼叫幾次等等。

Stub vs Mock
*Stub vs Mock

因此簡單來說,當我們只關心外部資料的回傳值時,就 Stub 此資料,如果在乎和外部環境的互動行為時,就 Mock 整個外部環境。

Stub 使用時機:只關心資料

上述的例子中,我們的 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' } 這個物件。

Mock 使用時機:在乎與環境的互動

和資料處理相關的 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)是否是我們所預期的。


上一篇
單元測試(一):Unit Tests 要寫些什麼?Side Effect 是怎麼樣的雷區?
下一篇
單元測試(三):前端有哪些重點 Unit Tests 要寫?
系列文
全端實戰心法:小團隊的產品開發大小事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言