使用 Promise 模式,寫出簡單易懂的 marionette test case

使用  Promise 模式,寫出簡單易懂的 marionette test case

在 Firefox OS 中,我們經常使用 marionette 這套測試框架來進行 web API 的測試,當 test case 越寫越多時,開始會有一些 bad smell 浮現,在這邊我們用 telephony 的 marionette test case 為例,看看目前的 test case 是長什麼樣子的,然後想想 — Could we do better?

請先大概瀏覽一下這兩個 test case:

  1. test_outgoing_answer_hangup.js
  2. test_outgoing_answer_local_hangup.js

你會發現,這兩個檔案看起來非常的像,看起來有許多重複的程式碼,沒錯這就是第一個問題:duplicated code,要寫出好的程式,有個很重要的 DRY(Don’t Repeat Yourself) 守則,我們要盡可能的讓同樣邏輯的程式碼只出現在一處。再來第二個問題則是,測試的流程無法一目了然,而且綁得很死。

在 test_outgoing_answer_hangup.js 中,測試流程可以歸納為這幾個步驟:

  1. 播出電話
  2. 對方接起
  3. 對方掛斷

而在 test_outgoing_answer_local_hangup.js 中則是:

  1. 播出電話
  2. 對方接起
  3. 我方掛斷

兩者的差別僅在第 3 步驟,我們希望在對方接起後(第 2 步驟)可以有不同的行為。

接下來讓我們更進一步看看在 test_outgoing_answer_hangup.js 中,測試的流程是如何控制的,想想怎樣才能共用多數的程式碼,彈性地寫出兩種 test case。

function dial() {
  outgoing = telephony.dial(number);
  outgoing.onalerting = function onalerting(event) {
    answer();
  };
}

function answer() {
  outgoing.onconnected = function onconnected(event) {
    hangUp();
  };
  emulator.run("gsm accept " + number);
}

function hangUp() {
  emulator.run("gsm cancel " + number);
}

hangUp() 的執行是寫死在 answer() 中的,如果我們希望可以選擇 answer() 後要做 hangUp() 或是 localHangUp(),則必須為 answer() 增加一個參數:

function answer(callback) {
  outgoing.onconnected = function onconnected(event) {
    callback();
  };
  emulator.run("gsm accept " + number);
}

如此便可使用 answer(hangUp) 或是 answer(localHangUp) 來呼叫。繼續往前追,answer() 是寫在 dial() 中的,所以同理,我們必須多給 dial() 一個參數,然後把 answer(hangUp) 傳進去,最後寫出來的就是:

function dial(callback) {
  outgoing = telephony.dial(number);
  outgoing.onalerting = function onalerting(event) {
    callback();
  };
}

function answer(callback) { ... }

// test_outgoing_answer_hangup
dial(function() {
  answer(hangUp);
});

// test_outgoing_answer_local_hangup
dial(function() {
  answer(localHangUp);
});

在這個例子中,因為我們的測試步驟只有簡單的 3 項,看起來還好,如果有 10 項呢?寫出來的程式碼就會有很深的巢狀,這個問題稱之為 callback hell,可以使用 Promise 改寫,把巢狀攤平。

Promise 是 javascript 中處理 asynchronous operation 的一種模式,當我們呼叫一個函式的時候,他回傳一個代表承諾的 Promise 物件,其內部有三種狀態:pending、resolved (fulfilled)、rejected,pending 代表操作還在進行,結果尚無法取得,隨著時間前進,其狀態可以轉移為 resolved 或是 rejected,以表示成功或失敗。Promise 物件可以用 .then(resolvedHandler, rejectedHandler) 串接,當狀態變為 resolved 或 rejected 時則執行對應的 handler,因此可以實現跟原本一樣的 callback 操作。其他關於 Promise 的使用與介紹,可以參考 MDN 文件 [1, 2, 3]。

接著我們把原本的 dial() 改寫為 Promise 形式,首先讓 dial() 執行後回傳一個 pending 狀態的 Promise 物件。

function dial(number) {
  let deferred = Promise.defer();
  ...
  return deferred.promise;
}

原本在 alerting event 發生時,dial 會執行 callback,這部份則改為 alerting 時,讓該 Promise 由 pending 轉為 resolved,並用 .then(callback) 串接,如此便能達到一樣的效果。

function dial(number) {
  let deferred = Promise.defer();
  let call = telephony.dial(number);

  call.onalerting = function onalerting(event) {
    deferred.resolve(call);
  };

  return deferred.promise;
}

dial(number).then(callback);

.resolve(call) 內傳入的參數,會成為 .then(callback) 中 callback 呼叫時的參數,也就是在 alerting 後,程式會呼叫 callback(call)。最後整個改寫後的程式碼如下:

let Promise = SpecialPowers.Cu.import("resource://gre/modules/Promise.jsm").Promise;

function dial(number) {
  let deferred = Promise.defer();
  let call = telephony.dial(number);

  call.onalerting = function onalerting(event) {
    deferred.resolve(call);
  };

  return deferred.promise;
}

function answer(call) {
  let deferred = Promise.defer();

  call.onconnected = function onconnected(event) {
    deferred.resolve(call);
  };
  emulator.run("gsm accept " + call.number);

  return deferred.promise;
}

function hangup(call) {
  let deferred = Promise.defer();

  call.ondisconnected = function ondisconnected(event) {
    deferred.resolve(call);
  };
  emulator.run("gsm cancel " + call.number);

  return deferred.promise;
}

function localHangUp(call) { ... }

// test_outgoing_answer_hangup
dial(number)
.then(answer)
.then(hangup);

// test_outgoing_answer_local_hangup
dial(number)
.then(answer)
.then(localHangUp);

可以看到,透過 Promise,我們可以把原本難看的深層巢狀攤平,兩個 test case 的執行流程都可以一目了然,不像先前要一個個 function 追進去。到目前為止,第二個問題解決了。而第一個 duplicated code 的問題呢?雖然這兩個 test case 的 dial()、answer() 是可以共用的,但我們必須將這兩個 case 分別寫在不同的兩個檔案,要怎樣才能讓 dial()、answer() 不在兩個檔案重複呢?Bug 805838 為 marionette test case 增加了一個 head.js 的功能,我們可以把 common code 放在 head.js 然後在各自的 test file 中寫:

MARIONETTE_HEAD_JS = 'head.js';

就可以有類似 include 的功能,問題一解決!

未來我們可以在 head.js 中放入 common 的 action function,例如 dail()、answer()、hangUp()、hold()、resume(),而在各個 test file 中,利用 Promise 的 .then(),就可以輕易串接出各種 test scenario!

[1] https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm
[2] https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm/Promise
[3] https://developer.mozilla.org/en-US/docs/Web/API/Promise