[譯]mock 是一種程式碼異味(軟體編寫)(第十二部分)

吳曉軍發表於2017-12-10

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)

(譯註:該圖是用 PS 將煙霧處理成方塊狀後得到的效果,參見 flickr。)

這是 “軟體編寫” 系列文章的第十一部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 < 上一篇 | << 返回第一篇

關於 TDD (Test Driven Development:測試驅動開發)和單元測試,我最常聽到的抱怨就是,開發者經常要和隔離單元所要求的 mock(模擬)作鬥爭。一些開發者並不知道單元測試真正意義所在。實際上,我發現開發者迷失在了他們單元測試檔案中的 mock(模擬)、fake(偽造物件)、和 stub(樁)(譯註:三者都是 Test Double(測試替身),可參看單元測試中 Mock 與 Stub 的淺析Unit Test - Stub, Mock, Fake 簡介),這些測試替身並沒有執行任何現實中實現的程式碼

另一方面,開發者容易陷入 TDD 的教條中,千方百計地要完成 100% 的程式碼覆蓋率,即便這樣做會使他們的程式碼越來越複雜。

我經常告訴開發者 mock 是一種程式碼異味(code smell),但大多數開發者的 TDD 技巧偏離到了追求 100% 單元測試覆蓋率的階段,他們無法想象去掉一個個的 mock 該怎麼辦。為了將 mock 置入到應用中,他們嘗試對測試單元包裹依賴注入函式,更糟糕地,還會將服務打包進依賴注入容器。

Angular 做得很極端,它為所有的元件新增了依賴注入,試圖讓人們將依賴注入看作是解耦的主要方式。但事實並非如此,依賴注入並不是完成解耦的最佳手段。

TDD 應該有更好的設計

學習高效的 TDD 的過程也是學習如何構建更加模組化應用的過程。

TDD 不是要複雜化程式碼,而是要簡化程式碼。如果你發現當你為了讓程式碼更可測試而犧牲掉程式碼的可讀性和可維護性時,或者你的程式碼因為引入了依賴注入的樣板程式碼而變臃腫時,你正在錯誤地實踐 TDD。

不要以為在專案中引入依賴注入就能模擬整個世界。它們未必能幫到你,相反還會坑了你。編寫更多的可測試程式碼本應當能夠簡化你的程式碼。它不僅要求更少的程式碼行數,還要求程式碼更加可讀、靈活以及可維護,依賴注入卻與此相反。

本文將教會你兩件事:

  1. 你不需要依賴注入來解耦程式碼
  2. 最大化程式碼覆蓋率將引起收益遞減(diminishing returns) —— 你越接近 100% 的覆蓋率,你就越可能讓你的應用變複雜,這與測試的目的(減少程式中的 bug)就背道而馳了。

更復雜的程式碼通常伴有更加臃腫的程式碼。你對整潔程式碼的渴望就像你對房屋整潔的渴望那樣:

  • 程式碼越臃腫,意味著 bug 有更多空間藏身,也就意味著程式將存在更多 bug。
  • 程式碼如果整潔精緻,你也不會迷失在當中了。

什麼是程式碼異味(code smell)?

“程式碼異味指的是系統深層次問題反映出來的表面跡象” ~ Martin Fowler

程式碼異味並不意味著某個東西完全錯了,或者是某個東西必須立即得到修正。它只是一個經驗法則,來提醒你要做出一些優化了。

本文以及本文的標題沒有暗示所有的 mock 都是不好的,也沒有暗示你別再使用 mock 了。

另外,不同型別的程式碼需要不同程度(或者說不同型別)的 mock。如果程式碼是為了方便 I/O 操作的,那麼測試就應當著眼於 mock I/O,否則你的單元測試覆蓋率將趨近於 0。

如果你的程式碼不存在任何邏輯(只含有純函式組成的管道或者組合),0% 的單元測試覆蓋率也是可以接受的,因為此時你的整合測試或者功能測試的覆蓋率接近 100%。然而,如果程式碼中存在邏輯(條件表示式,變數賦值,顯式函式呼叫等),你可能需要單元測試覆蓋率,此時你有機會去簡化你的程式碼以及減少 mock 需求。

mock 是什麼?

mock 是一個測試替身(test double),在單元測試過程中,它負責真正的程式碼實現。在整個測試的執行期內,一個 mock 能夠產生有關它如何被測試物件所操縱的斷言。如果你的測試替身產生了斷言,在特定的意義上,它就是一個 mock。

“mock”一詞更常用來指代任何測試替身的使用。考慮到本文的創作初衷,我們將交替使用 “mock” 和“測試替身”兩個詞以符合潮流。所有的測試替身(dummy、spy、fake 等等)都代表了與測試物件緊耦合的真實程式碼,因此,所有的測試替身都是耦合的標識,優化測試,也間接幫助優化了程式碼質量。與此同時,減少對於 mock 的需求能夠大幅簡化測試本身,因為你不再需要花費時間去構建 mock。

什麼是單元測試?

單元測試是測試單個工作單元(模組,函式,類),測試期間,將隔離單元與程式剩餘部分。

整合測試是測試兩個或多個單元間整合度的,功能測試則是從使用者視角來測試應用的,包含了完整的使用者互動工作流,從 mock UI 操作,到資料層更新,再到對使用者輸出(例如應用在螢幕上的展示)。功能測試是整合測試的一個子集,因為他們測試了應用的所有單元,這些單元整合在了當前執行應用的一個上下文中。

一般而言,只會使用單元的公共介面(也叫做 “公共 API” 或者 “表面積”)來測試單元。這被稱為黑盒測試。黑盒測試對於測試的健壯度更有利,因為對於某個測試單元,其公共 API 的變化頻度通常小於實現細節的變化頻度,即公共 API 一般是穩定的。如果你寫白盒測試,這種測試就能知道功能實現細節,因此任何實現細節的改變都將破壞測試,即便公共 API 的功能仍然不變。換言之,白盒測試會引起一些耗時的重複工作。

什麼是測試覆蓋率?

測試覆蓋率與被測試用例所覆蓋的程式碼數量有關。覆蓋率報告可以通過插樁(instrumenting)程式碼以及在測試期間記錄哪行程式碼被執行了來建立。一般來說,我們追求高測試覆蓋率,但是當覆蓋率趨近於 100% 時,將造成收益遞減。

個人而言,將測試覆蓋率提高到 90% 以上似乎也並不能再降低更多的 bug。

為什麼會這樣呢?100% 的覆蓋率不是意味著我們 100% 確定程式碼已經按照預期實現了嗎?

事實證明,沒那麼簡單。

大多數開發者並不知道其實存在著兩種覆蓋率:

  1. **程式碼覆蓋率:**測試單元覆蓋了多少程式碼邏輯
  2. **用例覆蓋率:**測試集覆蓋了多少用例

用例覆蓋率與用例場景有關:程式碼在真實環境的上下文將如何工作,該環境包含有真實使用者,真實網路狀況甚至還有黑客的非法攻擊。

覆蓋率標識了程式碼覆蓋上的弱點或威脅,而不是用例覆蓋上的弱點和威脅。相同的程式碼可能服務於不同的用例,單一用例可能依賴了當前測試物件以外的程式碼,甚至依賴了另一個應用或者第三方 API。

由於用例可能涉及環境、多個單元、使用者以及網路狀況,所以不太可能在只包含了一個測試單元的測試集下覆蓋所有所要求的用例。從定義上來說,單元測試對各個單元進行獨立地測試,而非整合測試,這也意味著,對於只包含了一個測試單元的測試集來說,整合或者功能用例場景下的用例覆蓋率趨近於 0%。

100% 的程式碼覆蓋率不能保證 100% 的用例覆蓋率。

開發者對於 100% 程式碼覆蓋率的追求看來是走錯路了。

什麼是緊耦合?

使用 mock 來完成單元測試中單元隔離的需求是由各個單元間的耦合引起的。緊耦合會讓程式碼變得呆板而脆弱:當需要改變時,程式碼更容易被破壞。一般來說,耦合越少,程式碼更易擴充套件和維護。錦上添花的是,耦合的減少也會減少測試對於 mock 的依賴,從而讓測試變得更加容易。

從中不難推測,如果我們正 mock 某個事物,就存在著通過減少單元間的耦合來提升程式碼靈活性的空間。一旦解耦完成,你將再也不需要 mock 了。

耦合反映了某個單元的程式碼(模組、函式、類等等)對於其他單元程式碼的依賴程度。緊耦合,或者說一個高度的耦合,反映了一個單元在其依賴被修改時有多大可能會損壞。換言之,耦合越緊,應用越難維護和擴充套件。鬆耦合則可以降低修復 bug 和為應用引入新的用例時的複雜度。

耦合會有不同形式的反映:

  • 子類耦合:子類依賴於整個繼承層級上父類的實現,這是物件導向中耦合最緊的形式。
  • 控制依賴:程式碼通過告知 “做什麼(what to do)” 來控制其依賴,例如,給依賴傳遞一個方法名給告訴依賴該做什麼等。如果控制依賴的 API 改變了,該程式碼就將損壞。
  • 可變狀態依賴:程式碼之間共享了可變狀態,例如,共享物件上的屬性可以被改變。可變物件變化時序的改變將破壞依賴該物件的程式碼。如果時序是不定的,除非你對所有依賴單元來個徹底檢修,否則就無法保證程式的正確性:一個例子就是當前存在一個無法修繕的競態紊亂。修復了某個 bug 可能又造成其他單元出現 bug。
  • 狀態形態依賴:程式碼之間共享了資料結構,並且只用了結構的一個子集。如果共享的結構發生了變化,那麼依賴於這個結構的程式碼也會損壞。
  • 事件/訊息 耦合:各個單元間的程式碼通過訊息傳遞、事件等進行通訊。

什麼造成了緊耦合?

緊耦合有許多成因:

  • 可變性不可變性
  • 副作用純度/隔離副作用
  • 職責過重單一職責(只做一件事:DOT —— Do One Thing)
  • 過程式指令描述性結構
  • 命令式組合宣告式組合

相較於函式式程式碼,命令式以及物件導向程式碼更易遭受緊耦合問題。這並非是說函數語言程式設計風格能讓你的程式碼免於緊耦合困擾,只是函式式程式碼使用了純函式作為組合的基本單元,並且純函式天然不易遭受緊耦合問題。

純函式:

  • 給定相同輸入,總是返回相同輸出
  • 不產生副作用

純函式是如何減少耦合的?

  • 不可變性:純函式不會改變現有的值,它總是返回新的值。
  • 沒有副作用:純函式唯一可觀測的作用就是它的返回值,因此,也就不會和其他觀測了外部變數的函式互動,例如螢幕、DOM、控制檯、標準輸出、網路以及磁碟。
  • 單一職責:純函式只完成一件事:對映輸入到對應的輸出,避免了職責過重時汙染物件以及基於類的程式碼。
  • 結構,而非指令:純函式可以被安全地記憶(memoized),這意味著,如果系統有無限的記憶體,任何純函式都能夠被替代為一個查詢表,該查詢表的索引是函式輸入,其在表中檢索到的值即為函式輸出。換言之,純函式描述了資料間的結構關係,而不是計算機需要遵從的指令,所以在同一時間執行兩套不同的有衝突的指令也不會造成問題。

組合能為 mock 做什麼?

一切皆可。軟體開發的實質是一個將大的問題劃分為若干小的、獨立的問題(分解),再組合各個小問題的解決方式來構成應用去解決大問題(合成)的過程。

當我們的分解策略失敗時,我們才需要 mock。

當測試單元把大問題分解為若干相互依賴的小問題時,我們需要引入 mock。換句話說,如果我們假定的原子測試單元並不是真正原子的,那麼就需要 mock,此時,分解策略也沒能將大的問題劃分為小的、獨立的問題。

當分解成功時,就能使用一個通用的組合工具來組合分解結果。例如下面這些:

  • 函式組合:例如有 lodash/fp/compose
  • 元件組合:例如 React 中使用函式組合來組合高階元件
  • 狀態 store/model 組合:例如 Redux combineReducers
  • 過程組合:例如 transducer
  • Promise 或者 monadic 組合:例如 asyncPipe(),使用 composeM()composeK() 的 Kleisli 組合。
  • 等等

當你使用通用組合工具時,組合的每個元素都可以在不 mock 其它的情況下進行獨立的單元測試。

組合自身將是宣告式的,所以它們包含了 0 個可單元測試的邏輯 (可以假定組合工具是一個自己有單元測試的第三方庫)。

在這些條件下,使用單元測試是沒有意義的,你需要使用整合測試替代之。

我們用一個大家熟悉的例子來比較命令式和宣告式的組合:

// 函式組合
// import pipe from 'lodash/fp/flow';
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// 待組合函式
const g = n => n + 1;
const f = n => n * 2;
// 命令式組合
const doStuffBadly = x => {
  const afterG = g(x);
  const afterF = f(afterG);
  return afterF;
};
// 宣告式組合
const doStuffBetter = pipe(g, f);
console.log(
  doStuffBadly(20), // 42
  doStuffBetter(20) // 42
);
複製程式碼

函式組合是將一個函式的返回值應用到另一個函式的過程。換句話說,你建立了一個函式管道(pipeline),之後向管道傳入了一個值,這個值將流過每個函式,這些函式就像是流水線上的某一步,在傳入下一個函式之前,這個值都會以某種方式被改變。最終,管道中的最後一個函式將返回最終的值。

initialValue -> [g] -> [f] -> result
複製程式碼

在每個主流程式語言中,無論這門語言是什麼正規化,組合都是組織應用程式碼的主要手段。甚至連 Java 也是使用函式(方法)作為兩個不同類例項間傳遞訊息的機制。

你可以手動地組合函式(命令式的),也可以自動地組合函式(宣告式的)。在非函式第一類(first-class functions)語言中,你別無選擇,只能以命令式的方式來組合函式。但在 JavaScript 中(以及其他所有主流語言中),你可以使用宣告式組合來更好地組織程式碼。

指令式程式設計風格意味著我們正在命令計算機一步步地做某件事。這是一種如何做(how-to)的引導。在上面的例子中,命令式風格就像在說:

  1. 接受一個引數並將它分配給 x
  2. 建立一個叫做 afterG 的繫結,將 g(x) 的結果分配給它。
  3. 建立一個叫做 afterF 的繫結,將 f(afterG) 的結果分配給它。
  4. 返回 afterF 的結果。

命令式風格的組合要求組合中牽涉的邏輯也要被測試。雖然我知道這裡只有一些簡單的賦值操作,但是我常在我傳遞或者返回錯誤的變數時,看到過(並且自己也寫過)bug。

宣告式風格的組合意味著我們告訴計算機事物之間的關係。它是一個使用了等式推理(equational reasoning)的結構描述。宣告式的例子就像在說:

  • doStuffBetter 函式 gf 的管道化組合。

僅此而已。

假定 fg 都有它們自己的單元測試,並且 pipe() 也有其自己的單元測試(在 Lodash 中是 flow(),在 Ramda 中是 pipe()),所以就沒有需要進行單元測試的新的邏輯。

為了讓宣告式組合正確工作,我們組合的單元需要被 解耦

我們如何消除耦合?

為了去除耦合,我們首先需要對於耦合來源有更好的認識。下面羅列了一些耦合的主要來源,它們被按照耦合的鬆緊程度進行了排序:

緊耦合:

  • 類繼承(耦合隨著每一層繼承和每一個子孫類而倍增)
  • 全域性變數
  • 其他可變的全域性狀態(瀏覽器 DOM、共享儲存、網路等等)
  • 引入了包含副作用的模組
  • 來自組合的隱式依賴,例如在 const enhancedWidgetFactory = compose(eventEmitter, widgetFactory, enhancements); 中,widgetFactory 依賴了 eventEmitter
  • 依賴注入容器
  • 依賴注入引數
  • 控制變數(一個外部單元控制了主題單元該做什麼事)
  • 可變引數

鬆耦合:

  • 引入的模組不包含副作用(在黑盒測試中,不是所有引入的模組都需要進行隔離)
  • 訊息的傳遞/釋出訂閱
  • 不可變引數(在狀態形態中,仍然會造成共享依賴)

諷刺的是,多數耦合恰恰來自於最初為了減少耦合所做的設計中。但這是可以理解的,為了能夠將小問題的解決方案重新組成完整的應用,單元彼此就需要以某種方式進行整合或者通訊。方式有好的,也有不好的。只要有必要,就應當規避緊耦合產生的來源,一個健壯的應用更需要的是鬆耦合。

對於我將依賴注入容器和依賴注入引數劃分到 “緊耦合” 分組中,你可能感到疑惑,因為在許多書上或者是部落格上,它們都被分到了 “鬆耦合” 一組。耦合不是個是非問題,它描述了一種程度。所以,任何分組都帶有主觀和獨斷色彩。

對於耦合鬆緊界限的劃分,我有一個立見分曉的檢驗方法:

測試單元是否能在不引入 mock 依賴的前提下進行測試?如果不行,那麼測試單元就 緊耦合 於 mock 依賴。

你的測試單元依賴越多,越可能存在耦合問題。現在我們明白了耦合是怎麼發生的,我們可以做什麼呢?

  1. 使用純函式 來作為組合的原子單元,而不是類、命令式過程或者包含可變物件的函式。
  2. 隔離副作用 與程式邏輯。這意味著不要將邏輯和 I/O(包括有網路 I/O、渲染的 UI、日誌等等)混在一起。
  3. 去除命令式組合中的依賴邏輯 ,這樣組合能夠變為自身不需要單元測試的、宣告式的組合。如果組合中不含邏輯,就不需要被單元測試。

以上幾點意味著那些你用來建立網路請求和操縱請求的程式碼都不需要單元測試,它們需要的是整合測試。

再嘮叨一下:

不要對 I/O 進行單元測試。

I/O 針對於整合測試。

在整合測試中,mock 和 fake(偽造)都是完全 OK 的。

使用純函式

純函式的使用需要多加練習,在缺乏練習的情況下,如何寫一個符合預期的純函式不會那麼清晰明瞭。純函式不能直接改變全域性變數以及傳給它的引數,如網路物件、磁碟物件或者是螢幕物件。純函式唯一能做的就是返回一個值。

如果你向純函式傳入了一個陣列或者一個物件,並且你要返回物件或者陣列變化了的版本,你不要直接改變並返回它們。你應當建立一個滿足對應變化的物件拷貝。對此,你可以考慮使用陣列的訪問器方法 (而不是 可變方法,例如 Array.prototype.spilceArray.prototype.sort 等),或在 Object.assign() 中新建立一個空物件作為目標物件,再或者使用陣列或者物件的展開語法。例子如下:

// 非純函式
const signInUser = user => user.isSignedIn = true;
const foo = {
  name: 'Foo',
  isSignedIn: false
};
// Foo 被改變了
console.log(
  signInUser(foo), // true
  foo              // { name: "Foo", isSignedIn: true }
);
複製程式碼

與:

// 純函式
const signInUser = user => ({...user, isSignedIn: true });
const foo = {
  name: 'Foo',
  isSignedIn: false
};
// Foo 沒有被改變
console.log(
  signInUser(foo), // { name: "Foo", isSignedIn: true }
  foo              // { name: "Foo", isSignedIn: false }
);
複製程式碼

又或者,你可以選擇一個針對於不可變物件型別的第三方庫,例如 Mori 或者是 Immutable.js。我希望有朝一日,在 JavaScript 中,有類似於 Clojure 中的不可變資料型別,但我可等不到那會兒了。

你可能覺得返回新的物件會造成一定的效能開銷,因為我們建立了新物件,而不是直接重用現有物件,但是一個利好是我們可以使用嚴格比較(也叫相同比較:identity equality)運算子(=== 檢查)來檢查物件是否發生了改變,這時,我們不再需要遍歷整個物件來檢測其是否發生了改變。

這個技巧可以讓你的 React 元件有一個複雜的狀態樹時渲染更快,因為你可能不需要在每次渲染時進行狀態物件的深度遍歷。繼承 PureComponent 元件,它通過狀態(state)和屬性(prop)的淺比較實現了 shouldComponentUpdate()。當它檢測到物件相同時,它便知道對應的狀態子樹沒有發生改變,因此也就不會再進行狀態的深度遍歷。

純函式也能夠記憶化(memoized),這意味著如果接收到了相同輸入,你不需要再重複構建完整物件。利用記憶體和儲存,你可以將預先計算好的結果存入一張查詢表中,從而降低計算複雜度。對於開銷較大、但不會無限需求記憶體的計算任務來說,這個是非常好的優化策略。

純函式的另一個屬性是,由於它們沒有副作用,就能夠在擁有大型叢集的處理器上安全地使用一個分治策略來部署計算任務。該策略通常用在處理影象、視訊或者聲音幀,具體說來就是利用服務於圖形學的 GPU 平行計算,但現在這個策略有了更廣的使用,例如科學計算。

換句話說,可變性不總是很快,某些時候,其優化代價遠遠大於優化受益,因此還會讓效能變慢。

隔離副作用與程式邏輯

有若干策略能幫助你將副作用從邏輯中隔離出來,下面羅列了當中的一些:

  1. 使用釋出/訂閱(pub/sub)來將 I/O 從檢視和程式邏輯中解耦出來。避免直接在 UI 檢視或者程式邏輯中呼叫副作用,而應當傳送一個事件或者描述了事件或意圖的動作(action)物件。
  2. 將邏輯從 I/O 中隔離出來,例如,使用 asyncPipe() 來組合那些返回 promise 的函式。
  3. 使用物件來描述未來的計算而不是直接使用 I/O 來驅動計算,例如 redux-saga 中的 call() 不會立即呼叫一個函式。取而代之的是,它會返回一個包含了待呼叫函式引用及所需引數的物件,saga 中介軟體則會負責呼叫該函式。這樣,call() 以及所有使用了它的函式都是純函式,這些函式不需要 mock,從而也利於單元測試。

使用 pub/sub 模型

pub/sub 是 publish/subscribe(釋出/訂閱) 模式的簡寫。在該模式中,測試單元不會直接呼叫彼此。取而代之的是,他們釋出訊息到監聽訊息的單元(訂閱者)。釋出者不知道是否有單元會訂閱它的訊息,訂閱者也不知到是否有釋出者會發布訊息。

pub/sub 模式被內建到了文件物件模型(DOM)中了。你應用中的任何元件都能監聽到來自 DOM 元素分發的事件,例如滑鼠移動、點選、滾動條事件、按鍵事件等等。回到每個人都使用 jQuery 構建 web 應用的時代,經常見到使用 jQuery 來自定義事件使 DOM 轉變為一個 pub/sub 的 event bus,從而將檢視渲染這個關注點從狀態邏輯中解耦出來。

pub/sub 也內建到了 Redux 中。在 Redux 中,你為應用狀態(被稱為 store)建立一個全域性模型。檢視和 I/O 操作沒有直接修改模型(model),而是分派一個 action 物件到 store。一個 action 有一個稱之為 type 的屬性,不同的 reducer 按照該屬性進行監聽及響應。另外,Redux 支援中介軟體,它們也可以監聽並且響應特殊的 action 型別。這種方式下,你的檢視不需要知道你的應用狀態是如何被操縱的,狀態邏輯也不需要知道關於檢視的任何事。

通過中介軟體,也能夠輕易地打包新的特性到 dispatcher 中,從而驅動橫切關注點(cross-cutting concerns),例如對 action 的日誌/分析,使用 storage 或者 server 來同步狀態,或者加入 server 和網路節點的實時通訊特性。

將邏輯從 I/O 中隔離

有時,你可以使用 monad 組合(例如組合 promise)來減少你組合當中的依賴。例如,下面的函式因為包含了邏輯,你就不得不 mock 所有的非同步函式才能進行單元測試:

async function uploadFiles({user, folder, files}) {
  const dbUser = await readUser(user);
  const folderInfo = await getFolderInfo(folder);
  if (await haveWriteAccess({dbUser, folderInfo})) {
    return uploadToFolder({dbUser, folderInfo, files });
  } else {
    throw new Error("No write access to that folder");
  }
}
複製程式碼

我們寫一些幫助函式虛擬碼來讓上例可工作:

const log = (...args) => console.log(...args);
// 下面這些可以無視,在真正的程式碼中,你會使用真實資料
const readUser = () => Promise.resolve(true);
const getFolderInfo = () => Promise.resolve(true);
const haveWriteAccess = () => Promise.resolve(true);
const uploadToFolder = () => Promise.resolve('Success!');
// 隨便初始化一些變數
const user = '123';
const folder = '456';
const files = ['a', 'b', 'c'];
async function uploadFiles({user, folder, files}) {
  const dbUser = await readUser({ user });
  const folderInfo = await getFolderInfo({ folder });
  if (await haveWriteAccess({dbUser, folderInfo})) {
    return uploadToFolder({dbUser, folderInfo, files });
  } else {
    throw new Error("No write access to that folder");
  }
}
uploadFiles({user, folder, files})
  .then(log)
;
複製程式碼

我們使用 asyncPipe() 來完成 promise 組合,實現對上面業務的重構:

const asyncPipe = (...fns) => x => (
  fns.reduce(async (y, f) => f(await y), x)
);
const uploadFiles = asyncPipe(
  readUser,
  getFolderInfo,
  haveWriteAccess,
  uploadToFolder
);
uploadFiles({user, folder, files})
  .then(log)
;
複製程式碼

因為 promise 內建有條件分支,因此,例子中的條件邏輯可以被輕鬆移除了。由於邏輯和 I/O 無法很好地混合在一起,因此我們想要從依賴 I/O 的程式碼中去除邏輯。

為了讓這樣的組合工作,我們需要保證兩件事:

  1. haveWriteAccess() 在使用者沒有寫許可權時需要 reject。這能讓分支邏輯轉到 promise 上下文中,我們不需要單元測試,也無需擔憂分支邏輯(promise 本身擁有 JavaScript 引擎支援的測試)。
  2. 這些函式都接受並且 resolve 某個資料型別。我們可以建立一個 pipelineData 型別來完成組合,該型別只是一個包含了如下 key 的物件:{ user, folder, files, dbUser?, folderInfo? }。它建立一個在各個元件間共享的結構依賴,在其它地方,你可以使用這些函式更加泛化的版本,並且使用一個輕量的包裹函式標準化這些函式。

當這些條件滿足了,就能很輕鬆地、相互隔離地、脫離 mock 地測試每一個函式。因為我們已經將組合管道中的所有邏輯抽出了,單元測試也就不再需要了,此時應當登場的是整合測試。

牢記:邏輯和 I/O 是相互隔離的關注點。 邏輯是思考,副作用(I/O)是行為。三思而後行!

使用物件來描述未來計算

redux-saga 所使用的策略是使用物件來描述未來計算。該想法類似於返回一個 monad,不過它不總是必須返回一個 monad。monad 能夠通過鏈式操作來組合函式,但是你可以手動的使用命令式風格程式碼來組合函式。下面的程式碼大致展示了 redux-saga 是如何做到用物件描述未來計算的:

// console.log 的語法糖,一會兒我們會用它
const log = msg => console.log(msg);
const call = (fn, ...args) => ({ fn, args });
const put = (msg) => ({ msg });
// 從 I/O API 引入的
const sendMessage = msg => Promise.resolve('some response');
// 從狀態操作控制程式碼或者 reducer 引入的
const handleResponse = response => ({
  type: 'RECEIVED_RESPONSE',
  payload: response
});
const handleError = err => ({
  type: 'IO_ERROR',
  payload: err
});

function* sendMessageSaga (msg) {
  try {
    const response = yield call(sendMessage, msg);
    yield put(handleResponse(response));
  } catch (err) {
    yield put(handleError(err));
  }
}
複製程式碼

如你所見,所有的單元測試中的函式呼叫都沒有 mock 網路 API 或者呼叫任何副作用。這樣做的好處還有:你的應用將很容易 debug,而不用擔心不確定的網路狀態等等......

當一個網路錯誤出現時,想要去 mock 看看應用裡將發生什麼?只需要呼叫 iter.throw(NetworkError)

另外,一些庫的中介軟體將驅動函式執行,從而在應用的生產環境觸發副作用:

const iter = sendMessageSaga('Hello, world!');
// 返回一個反映了狀態和值的物件
const step1 = iter.next();
log(step1);
/* =>
{
  done: false,
  value: {
    fn: sendMessage
    args: ["Hello, world!"]
  }
}
*/
複製程式碼

call() 中解構出 value,來審查或者呼叫未來計算:

const { value: {fn, args }} = step1;
複製程式碼

副作用只會在中介軟體中執行。當你測試和 debug 時你可以跳過這一部分。

const step2 = fn(args);
step2.then(log); // 將列印一些響應
複製程式碼

如果你不想在使用 mock API 或者執行 http 呼叫的前提下 mock 一個網路的響應,你可以直接傳遞 mock 的響應到 .next() 中:

iter.next(simulatedNetworkResponse);
複製程式碼

接下來,你可以繼續呼叫 .next() 直到返回物件的 done 變為 true,此時你的函式也會結束執行。

在你的單元測試中使用生成器(generator)和計算描述,你可以 mock 任何事物而不需要呼叫副作用。你可以傳遞值給 .next() 呼叫以偽造響應,也可以使用迭代器物件來丟擲錯誤從而 mock 錯誤或者 promise rejection。

即便牽涉到的是一個複雜的、混有大量副作用的整合工作流,使用物件來描述計算,都讓單元測試不再需要任何 mock 了。

“程式碼異味” 是警告,而非定律。mock 並非惡魔。

使用更優架構的努力是好的,但在現實環境中,我們不得不使用他人的 API,並且與遺留程式碼打交道,大部分這些 API 都是不純的。在這些場景中,隔離測試替身是很有用的。例如,express 通過連續傳遞來傳遞共享的可變狀態和模型副作用。

我們看到一個常見例子。人們告訴我 express 的 server 定義檔案需要依賴注入,不然你怎麼對所有在 express 應用完成的工作進行單元測試?例如:

const express = require('express');
const app = express();
app.get('/', function (req, res) {
  res.send('Hello World!')
});
app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
});
複製程式碼

為了 “單元測試” 這個檔案,我們不得不逐步建立一個依賴注入的解決策略,並在之後傳遞所有事物的 mock 到裡面(可能包括 express() 自身)。如果這是一個非常複雜的檔案,包含了使用了不同 express 特性的請求控制程式碼,並且依賴了邏輯,你可能已經想到一個非常複雜的偽造來讓測試工作。我已經見過開發者構建了精心製作的 fake 和 mock,例如 express 中的 session 中介軟體、log 操縱控制程式碼、實時網路協議,應有盡有。我從自己面對 mock 時的艱苦卓絕中得出了一個簡單的道理:

這個檔案不需要單元測試。

express 應用的 server 定義主要著眼於應用的 整合。測試一個 express 的應用檔案從定義上來說也就是測試程式邏輯、express 以及各個操作控制程式碼之間的整合度。即便你已經完成了 100% 的單元測試,也不要跳過整合測試。

你應當隔離你的程式邏輯到分離的單元,並分別對它們進行單元測試,而不應該直接單元測試這個檔案。為 server 檔案撰寫真正的整合測試,意味著你確實接觸到了真實環境的網路,或者說至少藉助於 supertest 這樣的工具建立了一個真實的 http 訊息,它包含了完成的頭部資訊。

接下來,我們重構 Hello World 的 express 例子,讓它變得更可測試:

hello 控制程式碼放入它自己的檔案,並單獨對其進行單元測試。此時,不再需要對應用的其他部分進行 mock。顯然,hello 不是一個純函式,因此我們需要 mock 響應物件來保證我們能夠呼叫 .send()

const hello  = (req, res) => res.send('Hello World!');
複製程式碼

你可以像下面這樣來測試它,也可以用你喜歡的測試框架中的期望(expectation)語句來替換 if

{
  const expected = 'Hello World!';
  const msg = `should call .send() with ${ expected }`;
  const res = {
    send: (actual) => {
      if (actual !== expected) {
        throw new Error(`NOT OK ${ msg }`);
      }
      console.log(`OK: ${ msg }`);
    }
  }
  hello({}, res);
}
複製程式碼

將監聽控制程式碼也放入它自己的檔案,並單獨對其進行單元測試。我們也將面臨相同的問題,express 的控制程式碼不是純函式,所以我們需要 mock logger 來保證其能夠被呼叫。測試與前面的例子類似。

const handleListen = (log, port) => () => log(`Example app listening on port ${ port }!`);
複製程式碼

現在,留在 server 檔案中的只剩下整合邏輯了:

const express = require('express');
const hello = require('./hello.js');
const handleListen = require('./handleListen');
const log = require('./log');
const port = 3000;
const app = express();
app.get('/', hello);
app.listen(port, handleListen(port, log));
複製程式碼

你仍然需要對該檔案進行整合測試,單多餘的單元測試不再能夠提升你的用例覆蓋率。我們用了一些非常輕量的依賴注入來把 logger 傳入 handleListen(),當然,express 應用可以不需要任何的依賴注入框架。

mock 很適合整合測試

由於整合測試是測試單元間的協作整合的,因此,在整合測試中偽造 server、網路協議、網路訊息等等來重現所有你會在單元通訊時、CPU 的跨叢集部署及同一網路下的跨機器部署時遇到的環境。

有時,你也想測試你的單元如何與第三方 API 進行通訊,這些 API 想要進行真實環境的測試將是代價高昂的。你可以記錄真實服務下的事務流,並通過偽造一個 server 來重現這些事務,從而測試你的單元和第三方服務執行在分離的網路程式時的整合度。通常,這是測試類似 “是否我們看到了正確的訊息頭?” 這樣訴求的最佳方式。

目前,有許多整合測試工具能夠節流(throttle)網路頻寬、引入網路延遲、建立網路錯誤,如果沒有這些工具,是無法用單元測試來測試大量不同的網路環境的,因為單元測試很難 mock 通訊層。

如果沒有整合測試,就無法達到 100% 的用例覆蓋率。即便你達到了 100% 的單元測試覆蓋率,也不要跳過整合測試。有時 100% 並不真的是 100%。

接下來

  • 在 Cross Cutting Concerns 播客上學習為什麼我認為[每一個開發團隊都需要使用 TDD]((https://crosscuttingconcerns.com/Podcast-061-Eric-Elliott-on-TDD)。
  • JavaScript 啦啦隊正在記錄我們在 Instagram 上的探險

需要 JavaScript 進階訓練嗎?

DevAnyWhere 能幫助你最快進階你的 JavaScript 能力,如組合式軟體編寫,函數語言程式設計一節 React:

  • 直播課程
  • 靈活的課時
  • 一對一輔導
  • 構建真正的應用產品

https://devanywhere.io/

Eric Elliott“編寫 JavaScript 應用” (O’Reilly) 以及 “跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章