實際進行集成測試的 Node.js 指南

已發表: 2022-03-11

集成測試不應該是可怕的。 它們是對您的應用程序進行全面測試的重要組成部分。

在談論測試時,我們通常會想到單元測試,我們在其中單獨測試一小塊代碼。 但是,您的應用程序比那一小段代碼要大,而且您的應用程序幾乎沒有任何部分是孤立地工作的。 這就是集成測試證明其重要性的地方。 集成測試彌補了單元測試的不足,它們彌合了單元測試和端到端測試之間的差距。

你知道你需要編寫集成測試,那你為什麼不這樣做呢?
鳴叫

在本文中,您將學習如何使用基於 API 的應用程序中的示例編寫可讀且可組合的集成測試。

雖然我們將在本文中的所有代碼示例中使用 JavaScript/Node.js,但討論的大多數想法都可以輕鬆地適應任何平台上的集成測試。

單元測試與集成測試:兩者都需要

單元測試專注於一個特定的代碼單元。 通常,這是特定方法或更大組件的功能。

這些測試是獨立完成的,所有外部依賴項通常都被存根或模擬。

換句話說,依賴關係被預先編程的行為所取代,確保測試的結果僅由被測試單元的正確性決定。

您可以在此處了解有關單元測試的更多信息。

單元測試用於維護具有良好設計的高質量代碼。 它們還使我們能夠輕鬆覆蓋極端情況。

然而,缺點是單元測試不能涵蓋組件之間的交互。 這就是集成測試變得有用的地方。

集成測試

如果單元測試是通過單獨測試最小的代碼單元來定義的,那麼集成測試正好相反。

集成測試用於在交互中測試多個更大的單元(組件),有時甚至可以跨越多個系統。

集成測試的目的是發現各種組件之間的連接和依賴關係中的錯誤,例如:

  • 傳遞無效或錯誤排序的參數
  • 損壞的數據庫架構
  • 緩存集成無效
  • 業務邏輯缺陷或數據流錯誤(因為現在從更廣泛的角度進行測試)。

如果我們正在測試的組件沒有任何復雜的邏輯(例如具有最小圈複雜度的組件),那麼集成測試將比單元測試重要得多。

在這種情況下,單元測試將主要用於強制執行良好的代碼設計。

雖然單元測試有助於確保正確編寫函數,但集成測試有助於確保系統作為一個整體正常工作。 因此,單元測試和集成測試都有各自的互補目的,並且對於全面的測試方法都是必不可少的。

單元測試和集成測試就像一枚硬幣的兩面。 沒有兩者,硬幣無效。

因此,在您完成集成和單元測試之前,測試是不完整的。

設置集成測試套件

雖然為單元測試設置測試套件非常簡單,但為集成測試設置測試套件通常更具挑戰性。

例如,集成測試中的組件可能具有項目外部的依賴項,如數據庫、文件系統、電子郵件提供商、外部支付服務等。

有時,集成測試需要使用這些外部服務和組件,有時它們可以被存根。

當需要它們時,可能會帶來一些挑戰。

  • 脆弱的測試執行:外部服務可能不可用、返回無效響應或處於無效狀態。 在某些情況下,這可能會導致誤報,有時可能會導致誤報。
  • 執行緩慢:準備和連接到外部服務可能很慢。 通常,測試作為 CI 的一部分在外部服務器上運行。
  • 複雜的測試設置:外部服務需要處於測試所需的狀態。 例如,數據庫應該預先加載必要的測試數據等。

編寫集成測試時要遵循的指導

集成測試沒有像單元測試那樣的嚴格規則。 儘管如此,在編寫集成測試時還是需要遵循一些通用的方向。

可重複測試

測試順序或依賴項不應改變測試結果。 多次運行相同的測試應該總是返回相同的結果。 如果測試使用 Internet 連接到第三方服務,這可能很難實現。 然而,這個問題可以通過存根和模擬來解決。

對於您有更多控制權的外部依賴項,在集成測試之前和之後設置步驟將有助於確保測試始終從相同的狀態開始運行。

測試相關操作

為了測試所有可能的情況,單元測試是一個更好的選擇。

集成測試更側重於模塊之間的連接,因此測試快樂的場景通常是要走的路,因為它將涵蓋模塊之間的重要連接。

可理解的測試和斷言

一個快速的測試視圖應該告訴讀者正在測試什麼,環境是如何設置的,什麼是存根的,何時執行測試,以及斷言了什麼。 斷言應該很簡單,並使用助手來更好地比較和記錄。

簡單的測試設置

讓測試進入初始狀態應該盡可能簡單易懂。

避免測試第三方代碼

雖然可以在測試中使用第三方服務,但沒有必要對其進行測試。 如果你不信任它們,你可能不應該使用它們。

讓生產代碼沒有測試代碼

生產代碼應該簡潔明了。 將測試代碼與生產代碼混合將導致兩個不可連接的域耦合在一起。

相關日誌

如果沒有良好的日誌記錄,失敗的測試並不是很有價值。

當測試通過時,不需要額外的日誌記錄。 但是當它們失敗時,廣泛的日誌記錄是至關重要的。

日誌記錄應包含所有數據庫查詢、API 請求和響應,以及斷言內容的完整比較。 這可以顯著方便調試。

好的測試看起來清晰易懂

遵循此處指南的簡單測試可能如下所示:

 const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));

上面的代碼正在測試一個 API ( GET /v1/admin/recipes ),它期望它返回一個保存的食譜數組作為響應。

您可以看到,儘管測試可能很簡單,但它依賴於許多實用程序。 這對於任何好的集成測試套件都很常見。

輔助組件使編寫可理解的集成測試變得容易。

讓我們回顧一下集成測試需要哪些組件。

輔助組件

一個全面的測試套件包含一些基本要素,包括:流控制、測試框架、數據庫處理程序以及連接到後端 API 的方法。

流量控制

JavaScript 測試中最大的挑戰之一是異步流程。

回調可能會對代碼造成嚴重破壞,而 Promise 是不夠的。 這就是流量助手變得有用的地方。

在等待 async/await 被完全支持時,可以使用具有類似行為的庫。 目標是編寫可讀的、有表現力的、健壯的代碼,並有可能具有異步流。

Co 使代碼能夠以一種很好的方式編寫,同時保持非阻塞。 這是通過定義一個協同生成器函數然後產生結果來完成的。

另一種解決方案是使用 Bluebird。 Bluebird 是一個 Promise 庫,具有非常有用的功能,例如處理數組、錯誤、時間等。

Co 和 Bluebird 協程的行為類似於 ES7 中的 async/await(在繼續之前等待解析),唯一的區別是它總是返回一個 Promise,這對於處理錯誤很有用。

測試框架

選擇測試框架取決於個人喜好。 我的偏好是一個易於使用、沒有副作用、並且輸出易於閱讀和管道傳輸的框架。

JavaScript 中有大量的測試框架。 在我們的示例中,我們使用的是磁帶。 在我看來,Tape 不僅滿足了這些要求,而且比 Mocha 或 Jasmin 等其他測試框架更乾淨、更簡單。

磁帶基於測試任何協議 (TAP)。

TAP 具有適用於大多數編程語言的變體。

Tape 將測試作為輸入,運行它們,然後將結果作為 TAP 輸出。 然後可以將 TAP 結果通過管道傳輸到測試報告器,或者以原始格式輸出到控制台。 磁帶從命令行運行。

Tape 有一些很好的特性,比如在運行整個測試套件之前定義一個要加載的模塊,提供一個小而簡單的斷言庫,以及定義應該在測試中調用的斷言的數量。 使用模塊進行預加載可以簡化測試環境的準備工作,並刪除任何不必要的代碼。

工廠圖書館

工廠庫允許您用更靈活的方式替換靜態夾具文件來生成測試數據。 這樣的庫允許您定義模型並為這些模型創建實體,而無需編寫凌亂、複雜的代碼。

JavaScript 有 factory_girl ——一個庫,靈感來自一個名稱相似的 gem,它最初是為 Ruby on Rails 開發的。

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');

首先,必須在 factory_girl 中定義一個新模型。

它使用名稱、項目中的模型和從中生成新實例的對象來指定。

或者,代替定義生成新實例的對象,可以提供一個返回對像或承諾的函數。

在創建模型的新實例時,我們可以:

  • 覆蓋新生成的實例中的任何值
  • 將附加值傳遞給構建函數選項

讓我們看一個例子。

 const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}

連接到 API

啟動一個成熟的 HTTP 服務器並發出一個實際的 HTTP 請求,然後在幾秒鐘後將其關閉——尤其是在執行多個測試時——完全是低效的,並且可能導致集成測試花費的時間比必要的長得多。

SuperTest 是一個 JavaScript 庫,用於在不創建新的活動服務器的情況下調用 API。 它基於 SuperAgent,一個用於創建 TCP 請求的庫。 使用此庫,無需創建新的 TCP 連接。 API 幾乎立即被調用。

SuperTest 支持承諾,是承諾的超級測試。 當這樣的請求返回一個 Promise 時,它可以讓您避免多個嵌套的回調函數,從而更容易處理流程。

 const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));

SuperTest 是為 Express.js 框架製作的,但只需稍作改動,它也可以與其他框架一起使用。

其他實用程序

在某些情況下,需要在我們的代碼中模擬一些依賴關係,使用間諜測試圍繞函數的邏輯,或者在某些地方使用存根。 這是其中一些實用程序包派上用場的地方。

SinonJS 是一個很棒的庫,它支持用於測試的間諜、存根和模擬。 它還支持其他有用的測試功能,例如彎曲時間、測試沙箱和擴展斷言,以及假服務器和請求。

在某些情況下,需要在我們的代碼中模擬一些依賴項。 對我們想要模擬的服務的引用被系統的其他部分使用。

為了解決這個問題,我們可以使用依賴注入,或者,如果這不是一個選項,我們可以使用像 Mockery 這樣的模擬服務。

Mockery 有助於模擬具有外部依賴關係的代碼。 為了正確使用它,應該在加載測試或代碼之前調用 Mockery。

 const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);

有了這個新的參考(在這個例子中, mockingStripe ),在我們的測試中更容易模擬服務。

 const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));

在 Sinon 庫的幫助下,很容易模擬。 這裡唯一的問題是這個存根會傳播到其他測試。 要對其進行沙箱處理,可以使用 sinon 沙箱。 有了它,以後的測試可以使系統恢復到初始狀態。

 const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();

需要其他組件來實現以下功能:

  • 清空數據庫(可以通過一個層次結構預構建查詢來完成)
  • 將其設置為工作狀態(sequelize-fixtures)
  • 模擬 TCP 請求到 3rd 方服務 (nock)
  • 使用更豐富的斷言 (chai)
  • 保存來自第三方的回复(易於修復)

不那麼簡單的測試

抽象和可擴展性是構建有效集成測試套件的關鍵要素。 將焦點從測試核心(準備其數據、操作和斷言)中移開的所有內容都應該分組並抽象為實用函數。

儘管這裡沒有正確或錯誤的路徑,因為一切都取決於項目及其需求,但任何好的集成測試套件仍然有一些關鍵品質。

以下代碼顯示瞭如何測試創建配方並發送電子郵件作為副作用的 API。

它會存根外部電子郵件提供商,以便您可以測試是否會在沒有實際發送的情況下發送電子郵件。 該測試還驗證 API 是否使用適當的狀態代碼進行響應。

 const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));

上面的測試是可重複的,因為它每次都從乾淨的環境開始。

它有一個簡單的設置過程,與設置相關的所有內容都合併到basicEnv.test函數中。

它只測試一個動作——一個 API。 它通過簡單的斷言語句清楚地說明了測試的期望。 此外,測試不涉及通過存根/模擬的第三方代碼。

開始編寫集成測試

在將新代碼推送到生產環境時,開發人員(以及所有其他項目參與者)希望確保新功能能夠正常工作而舊功能不會中斷。

如果沒有測試,這是很難實現的,如果做得不好會導致挫敗感、項目疲勞,並最終導致項目失敗。

集成測試,結合單元測試,是第一道防線。

僅使用兩者之一是不夠的,並且會為未發現的錯誤留下大量空間。 始終使用這兩者將使新的提交變得健壯,並為所有項目參與者帶來信心並激發信任。