Swift 进阶开发指南:如何使用 Quick、Nimble 执行测试驱动开发(TDD)

4,709 阅读10分钟

只要是在移动端应用上写任何类型的测试,这都不是一个受欢迎的选择,事实上,多数移动端应用开发团队都尽可能省略写测试的工作,希望借此教程来节省时间以加速开发进程。

自认为自己是一位技术成熟的开发者,我深刻体验了写测试带来的好处,不仅确保应用程序内的功能按预期运行,还可以锁定自己的代码,以防止其他开发人员更改代码,测试和代码之间的这种耦合可以帮助新开发人员轻松 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