React Native (RN) 是 Facebook 開源的跨平臺應用開發框架,由於 RN 提供的高效直觀的跨平臺開發模式和不錯的效能,我們在開發 Glow 的中文 App - 共樂孕的時候選擇了以 RN 為主要框架進行開發。

隨著開發模式的逐漸成熟,對RN專案的自動化測試也在不斷探索中慢慢完善, 最終選擇了 Detox (by Wix) 做 E2E 自動化測試, Jest (FaceBook) + Enzyme (Airbnb) 做整合測試和單元測試。

在這篇文章中我會介紹一下我對 React Native 專案自動化測試的核心想法以及自動化測試中 E2E 部分的具體實現。在

如何自動化測試 React Native 專案 (下篇) 中會詳細介紹單元測試的具體實現方法。

核心思想

先介紹一下對自動化測試的思考和對E2E,單元測試, 整合測試的優缺點以及重要性的想法:

自動化測試

自動化測試的重要性相信做過一段測試工作的人都有所瞭解, 簡單來說就是隨著 App feature 的不斷增長和支援的平臺、OS的增加, 測試 case 的數量會成倍的增長。
假設 App 有3個 feature 的時候, 測試用例有15個; 等App增長到有10個 feature 的時候,測試用例可能就增長到了 ~50 個。這樣每個版本開發的時候開發人員花在 feature 上的時間精力不會增加太多, 但對測試來說做迴歸測試的壓力就陡然增加。

如果沒有一套完整可靠的自動化測試, Team 可能只有兩個選擇 - 招更多的手工測試QA或者放棄掉一些迴歸測試 case 來保證 QA 能按時完成測試任務。無論哪種都是不scalable的方案。
自動化測試的重要性在這個時候就體現出來了:

  • 自動化測試可以提供高效的, 並且可重複的測試方法(重複勞動是人最不擅長的

  • 可以提高 Engineer team 的開發速度

  • 長久來看是比人工測試更可拓展, 可維護的。

測試金字塔

Test pyramid

測試金字塔 是目前比較流行的一種設計自動化測試的思路,核心觀點如下:

  1. 越下層的測試效率越高, 覆蓋率也越高, 開發維護成本越低

  2. 更上層的測試整合性更好, 但維護成本更高

  3. 大量的Unit測試, 少量的整合測試和更少的E2E測試是比較合理的平衡點(Google在blog中推薦70/20/10的測試用例個數比例)

簡單介紹一下對 Unit, Integration 以及 E2E 自動化測試的想法:

E2E 測試

E2E自動化測指通過UI來從頭到尾(End-To-End)的測試 App 的工作流程是不是符合預期。
E2E的優點是可以模擬使用者的真實scenario,代替手工測試來測試完整的整合系統。在任何自動化測試體系中,E2E都是最接近真實使用者的,因此是最讓人有信心的測試方法。

但實際應用中E2E測試的缺點也很明顯:

  • 要花很長時間才能找到真正的bug。 在fail的E2E case裡找root cause很痛苦。

  • E2E測試依賴於測試Build和測試環境。 經常E2E case掛了是因為各種非bug的原因,需要花時間和精力去維護測試Build和環境才能保證E2E case都pass。

  • 小的bugs很難被發現。 E2E case的assertion經常忽略掉不會影響整個Flow的bug, 但這些bug是不可接受的。

  • 不穩定性高。 經常測試指令碼因為一些意外的情況fail(比如網路慢, 測試機慢, 意外的彈出框 等等)。

  • 高維護成本。 當UI或者功能變化的時候, 維護E2E測試的成本是很高的,如果E2E帶來的收益還比不上維護他們的成本, 就得不償失了。

因此全部用E2E進行自動化測試是不現實的。 我個人之前也試過寫150+條E2E指令碼來進行測試, 後來維護指令碼的時間精力實在太大。因此我們需要更高效和容易維護的測試指令碼來代替E2E測試。

單元測試

單元測試通常指保證code中的一個單元正確工作的測試。 一個單元可以指一個方法, 一個class,甚至一個component; 可以按照code的結構進行劃分。

單元測試的優點如下:

  • 快速! 執行unit test和E2E相比是非常快的, 特別是mock了一些被測unit不關心的外部模組的時候, 比如network request, db request等等.

  • 可靠, 穩定。 不會因為一些外部原因意外的掛掉。

  • 獨立。當測試掛掉的時候可以很快的找到Bug的root cause。

單元測試的缺點在於無法保證每個單元都正確, 當他們都組裝在一起的時候也是正確的。

單元測試 vs. E2E測試

以上兩種測試方法各有各的好處,我們應該選擇利用兩者的優點,並且讓兩種測試方法的缺點帶來的風險更小。
這也符合前面測試金字塔中講過的觀點 - 用大量的單元測試來保證每個單元都是正確工作的, 同時用少量的更高層測試來保證整合起來也是正確工作的。

在維護自動化測試時,我的經驗是:

  • 當E2E測試暴露出一個bug的時候, 儘量用最底層的單元測試來重現這個bug, 然後新增一個單元測試來保證這個bug不會出現。

  • 如果單元測試無法重現這個bug, 再用更上層的整合測試或最高層的E2E測試來保證這個bug不會出現。

  • 在測試金字塔中, 把自動化測試指令碼儘量的‘推’到下層。

  • 應該儘量避免重複的測試, 即能用單元測試覆蓋的測試不要用整合測試或者E2E測試再實現一遍。

整合測試

之前講過單元測試的風險在於每個單元分別都是正確工作的不等於放在一起也是正確工作的。
這時候除了用E2E測試來做整合, 還可以用把幾個單元組裝在一起的整合測試的方法來減少這種風險。

整合測試的好處:

  • 可以測試和其他service的整合, 比如db/網路請求等等

  • 保證幾個單元組裝在一起的時候是正確工作的

  • 比E2E測試更小, 更好維護, 更集中在測試邏輯中, 同時減少單元測試的風險

Example

以上圖舉個例子: 比如 Module A 有5個Button A-E, 分別對應 Module A 的輸出1,2,3,4,5。
Module B也有5個Button A-E, 分別代表對 Module B 的輸入+1, +2, +3, +4, +5後輸出。
現在對這個系統設計測試用例:

方案1: 從黑盒的角度看, 如果把 Module A 和 B 當做一個整體, 那麼一共需要 5*5=25個測試用例去測。對A的5個button的每個選擇, B也有5個選擇可以選。

方案2: 從單元測試(白盒)的角度去看, Module A 和 B 分別需要5個單元測試來保證自己是正確工作的。
此外還應該有1條整合測試 case , 來保證Module A和B之前的資料互動是沒問題的(避免萬一資料從A到B之前發生變化或者type不一致)。
這條整合測試可以選擇Module A和B中的任意一種選擇, 只要保證他們之間的整合的正確性即可。

從這個例子可以看出單元測試的高效性, 因為獨立看每個單元只要負責自己module的邏輯正確性, 不依賴於module的輸入是什麼。
同時整合測試 case 保證了兩個module組裝在一起的時候也是正確工作的。 方案2一共有11個case,對比方案1的25個case效率就高了許多。
而且在未來的拓展中, 比如Module A添加了第6個選項(輸出6), 方案1就需要新增5個case, 但方案2依舊只需要新增1個case, 因為對 Module A 的單元來說只多了1條邏輯。

React Native 自動化測試的具體實現

我會在後文中具體介紹在 Glow 我們選擇用來實現這套自動化測試系統的框架以及詳細的實現方法。

  • 在E2E測試中我們選擇了 Wix 公司開源的 Detox 框架,相比傳統的測試框架Detox灰盒測試的方法在RN裡面有最好的穩定性。

  • 整合測試和單元測試選擇了 Jest 和 Enzyme (參考 下篇 )。 得益於 React Native 優秀的可測性和React良好生態環境, 整合/單元測試都可以用很直觀簡單的方式實現。

E2E自動化測試 - Detox

Detox是Wix公司開源的一款灰盒自動化測試框架。底層使用了Google開源的 Earl Grey(iOS)和 Espresso(Android)。

在詳細介紹Detox之前先簡單介紹下傳統黑盒自動化測試框架的特點和問題:

  • 傳統的黑盒測試框架的工作方式通常為根據 id 或者 text 等條件在 view hierarchy 中找目標元素,如果找不到就用sleep或者迴圈重試直到達到 timeout。 找到這個元素之後再做 action,如果找不到元素則會報錯。這種方式的特點是不知道在系統和 App 中發生了什麼, 把App當做黑盒去測試。

  • 測試經常因為不確定的隨機原因掛掉。 因為黑盒測試框架並不能正確得到App是否執行完之前的 action, 通常會寫很多 sleep 語句來保證 action 在執行時 App 處於閒置的狀態。

  • 在 React Native 中傳統的黑盒測試框架會遇到更多的問題, 因為RN有兩個 thread 控制 App 的渲染(js 執行緒和 native 執行緒),會更難控制 App 的行為。

  • RN App 第一次開啟的時候需要 load 和 parse js bundle, 黑盒測試框架需要sleep不確定的時間來等待這個過程(通常需要15到30秒)。

比如傳統的一些測試框架: Appium/Robotium/Calabash等, 當測試用例比較多的時候經常隨機的掛掉一些 case 但其實並沒有 bug;因為添加了大量 sleep 語句導致測試執行的很慢;setup 起來相對比較麻煩, 經常需要好幾個小時來搭建測試環境; Robotium 和 Calabash 的開發維護團隊幾乎已經停止支援這些框架了。

為了解決這個問題, Detox 利用 Earl Grey 和 Espresso 實現了灰盒的自動化測試。 特點如下:

  • 從 App 的內部來monitor App 的行為, 保證測試用例的指令和 App 的行為是同步的。

  • 和App在同一個程序中,可以訪問App執行時的記憶體, 可以monitor在程序中在執行的任務。比如網路請求是否完成/主執行緒是否空閒/其他的執行緒是不是空閒/Animation(動畫)是否結束/React Native Bridge是否空閒等等。

  • Detox會自動的監視App裡的所有Async任務, 確保App完全閒置, UI hierarchy也不會變化的時候再執行下一步。因此從根本上保證了測試用例和App行為的同步, 不需要加wait或者sleep條件來判斷 App 的狀態。

其他的一些優點:

  • Detox支援Android和iOS。我們的 React Native 在iOS和Android的程式碼幾乎相同, 因此也可以複用一套E2E的測試 case 。

  • 支援各種Test runner, 比如mocha, AVA,jest等。 可以根據個人喜好選擇, 我們為了和單元測試保持一致, 選擇了 Jest 作為 test runner。

  • 在 React Native 中可以根據TestID定位元素,對原本的程式碼侵入性較小(有些RN的測試框架需要額外的Component wrapper或者用ref來定位元素,侵入性相對較大)。

  • 搭建環境相對比較簡單。執行的時候只需要detox build命令來編測試app和detox test來執行指令碼即可。

  • Detox的特性自然保證了在測試剛開始執行的時候等待load和parse js bundle, 然後立刻開始執行測試指令碼。

  • 提供了API來reload React Native - await device.reloadReactNative();

  • 一些使用RN的大公司比如Microsoft, Callstack.io, Wix都貢獻了一些資源來維護開發這個框架, github社群也相對比較活躍。

著重介紹一下Detox自動同步的原理:

先舉個例子 - Detox case vs. Calabash (之前我們選用的測試框架,語言是ruby): 比如我們要點選ButtonA, 進入第二個頁面後點擊ButtonB. 2個頁面之間有一些animation和network request。 傳統的Calabash case可能會這麼寫:

touch "* id:'ButtonA'"  
wait_for_element_exists "* id: 'ButtonB'"  
sleep 2  
touch "* id: 'ButtonB'"  

原因是在 animation 的時候可能 ButtonB 已經在 View 裡存在了, 但其實是並不可點的(模擬器比較慢的時候更容易遇到)。為了減少 case 不必要的 fail, 就迫不得已的加了一些 sleep 語句。
如果sleep的時間少, 當測試執行的機器比較慢的時候就會 fail, sleep 多了自然 case 就慢了。

在detox的case寫起來就比較直觀了:

await element(by.id('ButtonA')).tap();  
await element(by.id('ButtonB')).tap();  

detox的每一個步測試方法都是 async 的, 當 ButtonA 被點選之後,App 的各種執行緒, animation, 網路請求, 非同步方法等等統統執行完畢,App 完全空閒的時候 tap 方法才會resolve。

因此當測試執行到第二步的時候, ButtonB一定是處於可點選狀態的, 不需要再用sleep或者wait方法來保證ButtonB的狀態。 這就是所謂的自動同步(Automatically synchronized)。(同步指測試指令碼和 App 的執行是按預期順序執行的)。

具體實現方式Detox的底層依賴於 Earl Grey 和 Espresso, 這兩個灰盒測試框架分別在 iOS 和 Android 的 native 程序了保證了測試框架和 App 同步。
利用 App 的內部資源或者監聽一些 callback 來得知 App 是否空閒; 並且只有在App空閒時執行下一步測試。 此外 Detox 在 React Native 的js執行緒裡也實現了類似的技術來得知JS是否執行完畢。

Detox 的測試指令碼有點是寫起來直觀,執行起來非常的穩定可靠和快速。 同時也有一些副作用比如:

  • 在程序中執行了額外的程式碼來監聽 App 的行為

  • 無限重複的動畫會讓指令碼一直處於等待狀態,需要額外的程式碼讓自動化測試的build去掉無限迴圈的動畫。

我覺得對我們來說值得承擔這些風險來獲得detox提供的在效率,可靠性方面的巨大提升。

最後附加一個 example 的E2E測試用例,可以看出 Detox 的 Api 還是很清晰易懂的,幾乎沒有什麼學習成本:

describe('Login flow', () => {

  it('should login successfully', async () => {
    await device.reloadReactNative();
    await expect(element(by.id('email'))).toBeVisible();

    await element(by.id('email')).typeText('[email protected]');
    await element(by.id('password')).typeText('123456');
    await element(by.text('Login')).tap();

    await expect(element(by.text('Welcome'))).toBeVisible();
    await expect(element(by.id('email'))).toNotExist();
  });

});