自動測試與 TDD 實務開發 - 上課心得 (中)

曾經有個工程師對著已經上線的網站說:「別說使用者不曉得這個系統是怎麼運作的,其實已經接手那麼久的我也不知道。」

如果你對這句話心有戚戚焉的話,那你真的不孤單。其實有很多維護維護前人程式碼的工程師在在接到新的需求而去修改程式碼時,常常是很戰戰競競的,然後辦公室裡就會響起這樣的聲音:

「我記得前一個工程師說這邊加幾行程式碼就可以了,你覺得呢?」
「我直覺這樣改會出問題...有環境讓我先測試看看嗎?」
「先不管,後天活動要上了,你先改完讓它上線後再說。」
「沒有測試的話...」
「閉嘴!快去改。」
不久後...
「慘了!訂單出大問題了!你可以先還原原來的程式碼嗎?」
「不對呀!我改這邊怎麼會讓那裡出錯?」
又過了一會兒...
「你有還原嗎?!整個訂單資料大亂了呀!你一定有動到什麼東西了!」
「我全還原了!現在跑的是我沒改過前的版本!」
「不管!加班修好它!」
「我不就說沒測試的話會有問題嗎!?為什麼不聽!?」
「你老闆我老闆?再靠杯試試看!」
三天後...
「夠了!拎杯走!我不想再改這個系統了! (摔杯子) 」

假設今天有時間讓你調整這個系統,在不知道線上系統整體如何運作的狀況下,你會怎麼讓它容易被維護與增添新功能呢?

上週所介紹的單元測試其實還沒辦法可以讓我們立刻套用在這樣的系統上,所以本週課程的重點就在:如何為已經上線的 legacy code 加上測試。

為了達成這個目標,講師介紹了兩個招式: Web Testing 與 Refactoring 。以下我就以我的方式來介紹我所學到的心得。

Web Testing

看過聖鬥士星矢的話,應該都知道雅典娜說過:「你不是還有生命嗎?」

無良老闆雅典娜

那麼回到問題點:現行的系統沒有測試怎麼辦?

對工程師來說,雅典娜的話就變成了:「你不是還有線上功能嗎?」

是呀!當系統是黑箱作業卻正常運作的時候,我們是從頁面 (介面) 上來確認的。換句話說,既然我們操作的方式是對的,而且畫面所得到結果也是我們所預期的,那麼對外部來說,這個已知功能就應該是正確的。所以我們可以先「對所有正確執行的功能建立測試」!只要在修改程式碼後,再跑相同的測試而沒有發生錯誤,就能證明我們的修改沒有影響到舊有程式碼。這實在是太酷了!

就網站來說,這樣的外部測試方式就稱為 Web Testing 。而如果所有的測試是以自動化腳本的形式存在,讓我們每次修改完程式碼後,可以方便地一次全部執行的話就太好了。最重要的,就是要有工具能幫我們產生這樣的腳本!而這種神一般的工具,就非 Selenium 莫屬了。

Selenium

Selenium 是一個讓瀏覽器自動化執行使用者操作流程的工具,通常用來當做測試的用途。它主要原理是利用 Selenium IDE 將使用者操作的過程錄製下來,並透過 Selenium IDE 或 Selenium WebDriver 執行該腳本,讓瀏覽器自動重現使用者操作過程。而它除了讓使用者操作的流程自動化之外,最厲害的地方是能夠把錄製下來的腳本轉換成不同的程式語言格式,融入到各語言的自動測試框架裡。

簡單介紹 Selenium 幾個要角:

  1. Selenium IDE : Firefox 的 Add-on ,用來錄製使用者在操作頁面時所有的動作與輸入,並且驗證輸出結果是否符合預期。而它的重播功能除了讓開發者快速驗證功能外,用來製作說明文件裡的操作範本也相當適合。
  2. Selenium WebDriver :告訴使用者的 IDE (例如 Visual Studio) 或 Selenium Server 如何啟動不同瀏覽器的中介層。常用的 WebDriver 包含了 IE 、 Firefox 、 Chrome 、 PhantomJS 等。 (原名 Selenium Remote Control)
  3. Selenium Server (選備) :用 Java 寫的 Daemon 服務;如果你的開發環境和測試用的瀏覽器不在同一台機器上,或是某些無法直接啟動瀏覽器的狀況下,你可能會需要透過 Selenium Server 來協助 WebDriver 啟動測試用的瀏覽器。 (原名 Selenium RC Server)

註:由於課程是使用 Visual Studio 來做本機開發與測試,所以只要用 Nuget 安裝 Selenium WebDriver 套件即可,不需要 Selenium Server 。

整個 Selenium 的使用流程如下:

  1. 在 Firefox 上安裝 Selenium IDE
  2. 視開發環境決定是否要啟動 Selenium Server ,或使用整合式的 Selenium WebDriver 。如果是 PHP 的話,不論是不是在本機執行,都一定要啟動 Selenium Server 。
  3. 啟動 Web 服務,讓 Firefox 連上測試網站。
  4. 針對所有功能錄製測試腳本 (包含操作流程與結果驗證) 。
  5. 重跑所有腳本,確認沒有操作上的問題。
  6. 利用 Selenium 的 Formatter 外掛,將 Selenium IDE 腳本轉換成對應的程式語言自動化測試框架的程式碼。
  7. 讓自動化測試框架透過 Selenium WebDriver 來完整執行所有的 Selenium 測試腳本,並確認各瀏覽器有被正常開啟。
  8. 接下來就可以繼續後續程式的新增功能或重構了。

而 Selenium WebDriver 詳細的使用方式在不同的語言有不同的作法,請分別參考我兩位好友的介紹:

註:之前我也寫過 Web UI 測試的好幫手 - Selenium ,雖然內容有點舊,但原理沒差太多。

常見的問題

像 Web Testing 這種由外部建立測試的方法,雖然在遇到大多數沒有撰寫測試的 legacy 系統時非常有用,但也不能說毫無缺點。當系統對環境依賴度相當高時,想重建一個供測試用的系統會變得非常困難,而這通常也是必須先克服的問題之一。

然後規模稍大,功能點較多的網站就會面臨第二個問題:「誰來錄操作流程?」答案很簡單:當然不是工程師。通常 legacy 系統一定會有瞭解它是如何使用的人,這時候必須借重老闆的影響力,想辦法讓這些人能夠協助列出系統所有的功能流程。

由於 Selenium IDE 非常易用,可以先花點時間教會這些人學習怎麼錄製操作流程。接下來請他們在邊列功能時,順手將流程錄製起來;這樣一來當功能列完的同時,就有完整的測試可以使用,還順便帶有操作示範呢。

另外若是錯誤的操作會讓程式出現非預期的結果的話,就應該歸類在原本就有的 bug 。但這也表示我們知道有這個操作流程應該要被避免,因此也要特別為它建立一組測試;一來可以在修改程式後透過這組測試確認程式的修改是正確的,二來也確保未來不會再讓這個錯誤發生。就像「同樣的招式不能對聖鬥士使用第二次」,同樣的 bug 也不該在系統上發生第二次。

FluentAutomation 從愛開始

Seleinum WebDriver 程式碼好難看懂怎麼辦?如果未來我加新功能而沒辦法錄製腳本時,要照著這樣寫測試嗎?當然不!我們需要讓測試腳本變得抽象化一些,看起來就像是用人話在描述規格一樣。

又是借重好用工具的時刻,在微軟體系上有個好用的 FluentAutomation 第三方套件,就是能將 Selenium 腳本語法包裝起來的語義化框架。

例如我們有這樣的規格:

// 登入成功
    // 我打開 Login 的網頁
    // 在 id 裡面輸入 user
    // 在 password 裡面輸入 pass
    // 按下登入
    // 期望應該導到首頁

用 FluentAutomation 寫出來的話,就會是:

private string baseUrl = @"http://localhost:29021/";

[TestMethod]
public void TestLoginSuccess()
{
    I.Open(baseUrl + "login")
        .Enter("user").In("#id")
        .Enter("pass").In("#password")
        .Click("input[type=\"submit\"]")
        .Assert.Url(baseUrl);
}

因為測試的主角是「我」,所以一切從 I 開始;而測試的對象是「網頁」,所以我們會有 Open (開啟網址) / Enter (輸入) / Click (點擊) 等操作方式;最後再使用 Assert 來驗證畫面輸出是否如我們所預期。整段程式碼是不是看起來更語意化呢?

除了語法更為抽象之外, FluentAutomation 執行的結果其實和 Selenium WebDriver 是一樣的。 FluentAutomation 能讓開發者融入測試的情境中,從使用者行為的角度出發去看待系統功能。一切就像用人話在說明功能,看起來就是這麼自然。

註: FluentAutomation 也支援多種不同瀏覽器同時測試,相關的說明請參考官方文件

註: PHP 也有類似的框架,是包含在 CodeceptionWebDriver 模組中。

Page Objects Pattern

課程中介紹另一個我覺得很棒的觀念是,雖然功能是一樣的,但如果頁面結構調整了怎麼辦?這時候就要用到物件導向最重要的觀念:封裝變化。

Page Objects Patterns 把頁面當成是一個物件,只讓它露出必要的行為,讓我們的主要流程只描述如何跟這個頁面互動,而不在乎它頁面上的細節。這樣一來不管頁面結構怎麼變化,只要主要功能不變,我們都只需要修改這個 Page Object 就好。

Page Objects 在 FluentAutomation 的實作範例可以參考官方說明的 PageObjects 一節。

同樣地在 PHP 的 Codeception 也支援 Page Objects Patterns ,請參考 PageObjects 一節。

Refactoring

假設我們已經有了 Web Testing 來確保我們的功能都經過了外部測試,那麼我們就有機會對它進行重構了。

課程中的範例可以說是這兩週課程的精華呀,從一個很難撼動的程式碼,一步一步重構成包含了 Web Testing 和單元測試的可維護可擴充架構。

這裡很難把它用三言兩言完整描述出來,恕我偷懶一下,這裡請直接參考講師以前寫的 30 天快速上手 TDD 之重構系列文章:

簡單說明一下整套重構流程的重點:

  1. 用 Selenium 錄下你的 Web 測試,越完整越好,而且讓它可以隨時執行。
  2. 理解你要重構的 legacy 程式碼意圖,把註解補上去。不用管細節,只要知道某段程式打算做什麼就好。
  3. 重構:想辦法把邏輯和 UI 分離開來,這個可能會因為語言或框架的不同而有不同的做法。
  4. 重構:應用常用的 Extract Method 技巧來將程式碼拆分出易懂的方法。
  5. 重構:依照職責分離的原則,把剛剛分離出來的方法引到新的類別中。
  6. 獨立出類別後,就可以把單元測試補上去了。接著所有測試都跑跑看吧。
  7. 重構:為各相似的類別抽出介面 (Interface) ,讓主程式去依賴介面,而不要依賴剛剛的類別。
  8. 重構:在抽出介面後,利用 Factory Method 將生成物件 (new) 的職責獨立出去。

要特別記住:

  • 每一步重構後都要測試,而且只要測試是綠燈,都是應該是能夠上線的狀態。
  • 通過測試後,就把程式碼 commit 到 VCS 裡,別偷懶。
  • 重構在搬移程式碼時,只修改 context ,而不要修改流程結構。
  • 修改流程結構 (例如 if 換成 switch ) 要自成一個重構。

避免增加中介層的獨立測試

這段是講師的壓箱寶,據說是這次課程才加入的,有聽有賺到。

這邊我打算用一句話來帶過:懶爸爸有個相依耦合的類別,把它抽出來放到可覆寫的方法中回傳;用個笨兒子來繼承懶爸爸,接著笨兒子用 DI 來注入相依物件的 stub object ;最後改測試笨兒子,結束。

如果看得懂上面這句,我想就能得到講師這段課程的精髓了。至於這個技巧多有用?如果發現類別中相依耦合度很高,卻又不確定這些類別被哪些程式用到,更擔心修改方法簽名就會影響整個系統時,你就會知道這個技巧的威力了。

心得

我必須老實說,不論是補測試或是重構,技術上絕對不是什麼大問題;像這次的課程所介紹的技巧,都沒有什麼非常困難的部份。那麼為什麼很多人不做呢?我想是因為大家都拿「新功能都做不完了,哪來時間做重構?」做為藉口了。但回到最文章最開頭的情境,其實真的花你時間的,反而不是這些測試或重構,而是你跟同事因為系統出問題的爭執。

如果老闆有心解決舊有系統的問題,就想辦法說服他加上測試並重構吧。

想瞭解更多的話,請報名「自動測試與 TDD 實務開發 」。