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

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

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

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

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

另外一方面,開發者容易陷入 TDD 的教條中,想方設法地要完成 100% 的代碼覆蓋率,即使這樣作會使他們的代碼愈來愈複雜。java

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

Angular 作得很極端,它爲全部的組件添加了依賴注入,試圖讓人們將依賴注入看做是解耦的主要方式。但事實並不是如此,依賴注入並非完成解耦的最佳手段。react

TDD 應該有更好的設計

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

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

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

本文將教會你兩件事:github

  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前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索