在mobile app專案上寫任何類型的測試都不是一個受歡迎的選擇,事實上,多數mobile應用開發團隊都盡可能省略寫測試的工作,希望藉此節省時間以加速開發時程。
身為一位「成熟」的開發者,我深刻體驗了寫測試帶來的好處,不僅確保應用程式內的功能按預期運行,還可以「lock」你的程式碼,以防止其他開發人員更改代碼,測試和程式碼之間的這種耦合可以幫助新開發人員輕鬆onboard或接管專案。
Test-driven Development
Test-Driven Development (TDD) 就像是一個寫code的新藝術。它遵循以下循環:
- 先寫一個會fail的測試
- 補上程式碼讓它通過測試
- Refactor(重構)
- 重複以上動作至滿意為止
這邊提供給讀者一個簡單的例子,請參考以下實作範例:
func calculateAreaOfSquare(w: Int, h: Int) -> Double { }
Test 1:
給定w=2
、h=2
,預期輸出結果會是4
,在上面的程式碼當中,這個測試結果會是fail,因為我們還沒實作裡面的內容。
接著,我們添加一些程式碼:
func calculateAreaOfSquare(w: Int, h: Int) -> Double { return w * h }
第一個測試現在就可以通過了!呦呼!
Test 2:
給定w=-1
,h=-1
,我們預期的面積計算結果應該要是0
,在這個範例中,測試又出現fail了,因為按照目前函式的實作方法,它的輸出結果為1
。
接著,我們添加一些程式碼:
func calculateAreaOfSquare(w: Int, h: Int) -> Double { if w > 0 && h > 0 { return w * h } return 0 }
現在第二個測試也通過了,太棒了!
持續這個動作,直到處理所有的極端情況(edge cases),同時,也要進行重構讓程式碼變得更好,並通過所有的測試。
根據我們目前為止所討論的,我們了解到TDD不僅可以創造出更有品質的程式碼,而且可以讓開發者提前處理極端狀況。此外,它還能讓兩個開發人員有效率的進行結對程式設計(pair-programming),一位工程師攥寫測試,另一位則編寫能夠通過測試的code,你可以透過Dotariel的博客文章了解更多細節。
在這篇教程中你將學到什麼
在本教程的尾聲,你應該能帶走下列這些知識:
- 能基本了解為什麼TDD很好。
- 基本了解到Quick & Nimble如何操作。
- 了解如何使用Quick & Nimble編寫一個UI測試。
- 了解如何使用Quick & Nimble編寫一個Unit Test(單元測試)。
習前準備作業
在進入本文重點之前,以下是一些開發環境準備工作:
- 安裝完成Xcode 8.3.3並使用Swift 3.1開發
- 具備一些Swift和iOS開發經驗
設定我們的專案
假設我們被指定一個任務是開發一個可以陳列電影資訊的簡單電影應用程式,先啟動Xcode並創建一個新的Single View Application,命名為MyMovies,並把Unit Tests
勾選起來,當設定完函式庫(libraries)和視圖控制器(view controllers),我們會重新訪問這個target。
接下來,讓我們刪除原有的ViewController
並拖進一個UITableViewController
,將它命名為MoviesTableViewController
,在Main.storyboard
中,刪除ViewController
,並拉進一個新的TableViewController
,並將類別設置為MoviesTableViewController
。現在,我們將prototype cell的style設置為Subtitle
,將identifier設置為MovieCell
,以便我們稍後可以顯示電影的title
和genre
。
記得要將這個view controller設定為initial view controller
,如下圖。
截至目前為止,你的程式碼應該是這樣的:
import UIKit class MoviesTableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() } // MARK: - Table view data source override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 0 } }
Movies
現在,讓我們來創建電影的數據,以便稍後使用它來填充我們的視圖。
Genre Enum
enum Genre: Int { case Animation case Action case None }
這個枚舉(enum)用於判別我們的電影類型。
Movie Struct
struct Movie { var title: String var genre: Genre }
這個電影數據類型(movie data type)用於表示我們的個別電影數據。
class MoviesDataHelper { static func getMovies() -> [Movie] { return [ Movie(title: "The Emoji Movie", genre: .Animation), Movie(title: "Logan", genre: .Action), Movie(title: "Wonder Woman", genre: .Action), Movie(title: "Zootopia", genre: .Animation), Movie(title: "The Baby Boss", genre: .Animation), Movie(title: "Despicable Me 3", genre: .Animation), Movie(title: "Spiderman: Homecoming", genre: .Action), Movie(title: "Dunkirk", genre: .Animation) ] } }
這個MoviesDataHelper類別幫助我們直接調用getMovies
方法,以便我們可以透過單一調用中獲取電影數據。
我們注意到在這個階段,還沒有執行任何TDD,因為目前仍在專案的設定工作,現在讓我們進入到本教程的主要內容,Quick & Nimble!
Quick & Nimble
Quick是基於XCTest構建的測試開發框架,它能支援Swift和Objective-C,並提供了一個DSL來編寫測試,非常類似於RSpec。
Nimble就像是Quick的夥伴,Nimble提供Matcher做為Assertion,有關框架的更多訊息,請查看這個連結。
使用Carthage安裝Quick & Nimble
隨著Carthage的發展,讓我喜歡Carthage更甚於Cocoapods,因為它更分散化,當其中一個framework無法構建時,整個專案仍然可以編譯。
#CartFile.private github "Quick/Quick" github "Quick/Nimble"
以上為CartFile.private
,用來安裝我的dependencies,如果讀者沒有使用Carthage的任何經驗,請查看此連結。
將CartFile.private
放置在文件夾中,然後運行carthage update
,它將clone這個dependencies,讀者應該會在你的Carthage -> Build -> iOS
文件夾中獲得兩個框架。然後,將兩個框架添加到兩個測試target中,接者,還需要去Build Phases,點擊左上角的加號,然後選擇”New Copy Files Phase”,將destination設置為”Frameworks”,並在其中添加兩個框架。
開始吧!你現在已經將本文所需的測試函式庫全部設置完成!
編寫我們的Test #1
讓我們來開始寫第一個測試,我們都知道我們有一個列表,也有一些電影數據,如何確保列視圖顯示的項目數量正確?沒錯!我們需要確保TableView的row與我們的電影數據的數量相匹配。這就是我們的第一個測試,所以現在來看看我們的MyMoviesTests
,刪除XCTest程式碼並導入我們的Quick和Nimble套件!
這邊必須確保我們的class是QuickSpec
的子類,它也是原本XCTestCase
的子類,要了解Quick & Nimble
的底層仍是XCTest
,在這裡我們需要做的最後一件事是宣告一個override function spec()
,這裡我們用來定義一套Example Groups and Examples。
import Quick import Nimble @testable import MyMovies class MyMoviesTests: QuickSpec { override func spec() { } }
在這種情況下,我們將使用大量的使用it
、describe
和context
來編寫我們的測試。其中,每個it代表⼀⼩段測試,describe
和context
則是it
範例的邏輯群集(logical groupings),用來描述你要測試的是什麼。
Test #1 – 預期TableView Rows Count = Movies Data Count
首先,來引入我們的subject
,它是我們的視圖控制器。
import Quick import Nimble @testable import MyMovies class MyMoviesTests: QuickSpec { override func spec() { var subject: MoviesTableViewController! describe("MoviesTableViewControllerSpec") { beforeEach { subject = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "MoviesTableViewController") as! MoviesTableViewController _ = subject.view } } } }
請注意,我們在這裡放置@testable import MyMovies,這一行基本上就是標示出我們正在測試的專案目標,然後允許我們從那裡import classes。當我們測試TableViewController的視圖層時,需要從storyboard中獲取一個實例。
describe閉包(closure)開始我的第一個測試案例,為MoviesTableViewController
編寫測試。
beforeEach閉包會在describe閉包中執行,它將在每個範例開始之前運行,所以你可以把它看作為在MoviesTableViewController內的每一個測試被執行前,會先運行這段程式碼。
_ = subject.view
將視圖控制器放入內存中,它就像是調用viewDidLoad
。
最後,我們可以在beforeEach { }
之後添加我們的test assertion,如下所示:
context("when view is loaded") { it("should have 8 movies loaded") { expect(subject.tableView.numberOfRows(inSection: 0)).to(equal(8)) } }
這邊來講解一下,我們有一個context
,它是一個grouped example closure,被標示為when view is loaded
,接著是主要範例it should have 8 movies loaded
,我們可以預期或宣稱我們的table view的行數為8,現在讓我們按下CMD + U來運行測試,或者依照Product -> Test路徑進行測試,在幾秒鐘後你將在控制台中獲得此訊息:
MoviesTableViewController__when_view_is_loaded__should_have_8_movies_loaded] : expected to equal <8>, got <0> Test Case '-[MyMoviesTests.MoviesTableViewControllerSpec MoviesTableViewController__when_view_is_loaded__should_have_8_movies_loaded]' failed (0.009 seconds).
所以你剛剛寫了一個失敗的測試,接下來我們要來修復它,開始操作TDD吧!
Fix Test #1
我們回到主要的MoviesTableViewController
並加載我們的電影數據!添加這些code之後,再次運行測試,替自己首次通過測試喝采吧!
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return MoviesDataHelper.getMovies().count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MovieCell") return cell! }
讓我們回顧一下,你剛剛寫了一個失敗的測試,然後透過三行程式碼修復它,現在它通過了,這就是我們所說的TDD,能確保高品質、良好codebase的方法。
編寫我們的Test #2
現在是時候用第二個test case來替本教程劃下句點,如果我們運行應用程式,就只是在各個地方設置”title”和”subtitle”,我們錯過了實際的電影數據!為此來為UI寫一個測試吧!
來看看我們的spec文件。引入一個新的context
調用Table View
。從table view中抓取第一個cell,並測試數據是否匹配。
context("Table View") { var cell: UITableViewCell! beforeEach { cell = subject.tableView(subject.tableView, cellForRowAt: IndexPath(row: 0, section: 0)) } it("should show movie title and genre") { expect(cell.textLabel?.text).to(equal("The Emoji Movie")) expect(cell.detailTextLabel?.text).to(equal("Animation")) } }
現在運行測試會看到它們fail了。
MoviesTableViewController__Table_View__should_show_movie_title_and_genre] : expected to equal, got
同樣的,我們需要修復這個測試!需要給我們的cell labels顯示正確的數據。
Fix Test #2
我們先前將Genre做為enum之用,這裡來擴充更多的code,所以參考下圖程式碼更新Movie
:
struct Movie { var title: String var genre: Genre func genreString() -> String { switch genre { case .Action: return "Action" case .Animation: return "Animation" default: return "None" } } }
這裡來更新我們的cellForRow
方法:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MovieCell") let movie = MoviesDataHelper.getMovies()[indexPath.row] cell?.textLabel?.text = movie.title cell?.detailTextLabel?.text = movie.genreString() return cell! }
萬歲!你剛剛通過了你的第二個test case!在這個時刻,我們來看看可以重構的內容,嘗試使程式碼更簡潔,但仍要可以通過所有的測試,
我們刪除空的函數,並將我們的getMovies()
宣告為計算屬性(computed property)。
class MoviesTableViewController: UITableViewController { var movies: [Movie] { return MoviesDataHelper.getMovies() } // MARK: - Table view data source override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return movies.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "MovieCell") let movie = movies[indexPath.row] cell?.textLabel?.text = movie.title cell?.detailTextLabel?.text = movie.genreString() return cell! } }
如果再次運行測試,所有測試仍應通過,嘗試看看吧!
總結
那麼我們完成了哪些事呢?
- 我們寫了第一個測試來檢查電影數量,並且讓它fail。
- 我們實作邏輯來加載電影,然後讓它pass。
- 我們寫了第二個測試來檢查是否正確顯示,並且讓它fail。
- 我們實現顯示邏輯,然後讓測試pass。
- 然後暫停測試工作,接著進行refactor。
以上通常就是TDD的運作流程,你可以繼續使用此專案來嘗試更多的測試工作,如果你對本教程有任何疑問,請在下面留下你的意見,讓我知道。
對於範例專案,讀者可以在GitHub下載完整的source code。
FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS
原文:Test Driven Development (TDD) in Swift with Quick and Nimble