Swift 進階開發指南:如何使用 Quick、Nimble 執行測試驅動開發(TDD)

貓D發表於2019-03-04

只要是在移動端應用上寫任何型別的測試,這都不是一個受歡迎的選擇,事實上,多數移動端應用開發團隊都儘可能省略寫測試的工作,希望藉此教程來節省時間以加速開發程式。

自認為自己是一位技術成熟的開發者,我深刻體驗了寫測試帶來的好處,不僅確保應用程式內的功能按預期執行,還可以鎖定自己的程式碼,以防止其他開發人員更改程式碼,測試和程式碼之間的這種耦合可以幫助新開發人員輕鬆 onboard 或接管專案。

Test-driven Development

Test-Driven Development (TDD) 就像是一個寫 code 的新藝術。它遵循以下迴圈:

  • 先寫一個會fail的測試
  • 補上程式碼讓它通過測試
  • Refactor(重構)
  • 重複以上動作至滿意為止

這邊提供給讀者一個簡單的例子,請參考以下操作範例:

func calculateAreaOfSquare(w: Int, h: Int) -> Double { }複製程式碼

Test 1:

給定w=2h=2,預期輸出結果會是4,在上面的程式碼當中,這個測試結果會是fail,因為我們還沒實作裡面的內容。

接著,我們新增一些程式碼:

func calculateAreaOfSquare(w: Int, h: Int) -> Double { return w * h }複製程式碼

第一個測試現在就可以通過了!

(adsbygoogle = window.adsbygoogle || []).push({});複製程式碼

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。

TDD Sample Project
TDD Sample Project

接下來,讓我們刪除原有的 ViewController 並拖進一個 UITableViewController ,將它命名為 MoviesTableViewController ,在Main.storyboard 中,刪除 ViewController,並拉進一個新的 TableViewController ,並將類別設定為 MoviesTableViewController 。現在,我們將prototype cell的style設定為 Subtitle,將identifier設定為 MovieCell,以便我們稍後可以顯示電影的 titlegenre

記得要將這個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

讓我們來開始寫第一個測試,我們都知道我們有一個列表,也有一些電影資料,如何確保列檢視顯示的專案數量正確?沒錯!我們需要確保TableViewrow與我們的電影資料的數量相匹配。這就是我們的第一個測試,所以現在來看看我們的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() {
    }
}複製程式碼

在這種情況下,我們將使用大量的使用itdescribecontext來編寫我們的測試。其中,每個it代表⼀⼩段測試,describecontext 則是 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__shou複製程式碼

所以你剛剛寫了一個失敗的測試,接下來我們要來修復它,開始操作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,能確保高品質、良好 codebas 的方法。

編寫我們的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 <Animation>, got <Subtitle>複製程式碼

同樣的,我們需要修復這個測試!需要給我們的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

原文Test Driven Development (TDD) in Swift with Quick and Nimble

相關文章