2017WWDC Engineering for Testability

主要讲解了在产品开发过程中,如何将代码变得具有“可测性”,这需要通过比如:添加辅助的对象、协议、参数来避免不可控环境对代码运行的影响;重构代码使得方法的状态明确,避免无输入输出状态不可测的情况。同样的,我们的测试代码本身也需要“可扩展性”比如避免写死的调用逻辑,以及我们对待测试代码本身也需要注意代码的质量。

简单来说,就是我们在写代码的过程中,要时刻想着是不是写出的代码比较容易的写相应的测试代码,写测试代码也得想着以后有改动的话,是不是很容易?

Testable App Code

Structure of a Unit Test:准备输入->跑代码->验证输出
Characteristics of Testable Code:可控输入+可见输出+显性状态

Testability Techniques

Protocols and parameterization

使用一个具体的“调用第三方APP打开一个文件”例子openTapped

@IBAction func openTapped(_ sender: Any) {
    let mode: String
    switch segmentedControl.selectedSegmentIndex {
    case 0: mode = "view"
    case 1: mode = "edit"
    default: fatalError("Impossible case")
    }
    let url = URL(string: "myappscheme://open?id=\(document.identifier)&mode=\(mode)")!
    if UIApplication.shared.canOpenURL(url) {
        UIApplication.shared.open(url, options: [:], completionHandler: nil)
    } else {
        handleURLError()
    }
}

这种类型看起来应该使用UITesting,但是它实际上并不是太与UI相关,而是应该使用Unit Test,这里就来介绍如何实现可测性:

protocol URLOpening {
  func canOpenURL(_ url: URL) -> Bool
  func open(_ url: URL, options: [String: Any], completionHandler: ((Bool) -> Void)?)
}
extension UIApplication: URLOpening {
  // Nothing needed here!
}

class MockURLOpener: URLOpening {
    var canOpen = false
    var openedURL: URL?
    func canOpenURL(_ url: URL) -> Bool {
        return canOpen
    }
    func open(_ url: URL,
              options: [String: Any],
              completionHandler: ((Bool) -> Void)?) {
        openedURL = url
    }
}

这里的思想主要是使用Mock的代码来避免了实际调用需要跳转以及依赖于具体环境的问题,实现对当前方法功能的测试覆盖

func testDocumentOpenerWhenItCanOpen() {
    let urlOpener = MockURLOpener()
    urlOpener.canOpen = true
    let documentOpener = DocumentOpener(urlOpener: urlOpener)
    documentOpener.open(Document(identifier: "TheID"), mode: .edit)
    XCTAssertEqual(urlOpener.openedURL, URL(string: "myappscheme://open?id=TheID&mode=edit"))
}

最后又总结了一下

Reduce references to shared instances
Accept parameterized input
Introduce a protocol
Create a testing implementation

Separating logic and effects

这里使用一个“缓存清理”的例子,来介绍在无状态输出的情况下如何重构代码来实现可测性:

func cleanCache(maxSize: Int) throws {
    let sortedItems = self.currentItems.sorted { $0.age < $1.age }
    var cumulativeSize = 0
    for item in sortedItems {
        cumulativeSize += item.size
        if cumulativeSize > maxSize {
            try FileManager.default.removeItem(atPath: item.path)
        }
    }
}

这个方法没有返回值,也就是不能通过方法本身来知道它到底做了什么,是否成功。这里采用的方法是重构代码,将就有可测性的代码提取出来为一个新的方法:

protocol CleanupPolicy {
    func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item>
}

class OnDiskCache {
    func cleanCache(maxSize: Int) throws { /* … */ }
}

struct MaxSizeCleanupPolicy: CleanupPolicy {
    let maxSize: Int
    func itemsToRemove(from items: Set<OnDiskCache.Item>) -> Set<OnDiskCache.Item> {
        var itemsToRemove = Set<OnDiskCache.Item>()
        var cumulativeSize = 0
        let sortedItems = allItems.sorted { $0.age < $1.age }
        for item in sortedItems {
            cumulativeSize += item.size
            if cumulativeSize > maxSize {
                itemsToRemove.insert(item)
            }
        }
        return itemsToRemove
    }
}

class OnDiskCache {
    /* … */
    func cleanCache(policy: CleanupPolicy) throws {
        let itemsToRemove = policy.itemsToRemove(from: self.currentItems)
        for item in itemsToRemove {
            try FileManager.default.removeItem(atPath: item.path)
        }
    }
}

这样的话,我们就可以比较容易的对MaxSizeCleanupPolicy进行单元测试,而避免将不具有显性状态的的文件删除逻辑混在一起:

func testMaxSizeCleanupPolicy() {
    let inputItems = Set([
        OnDiskCache.Item(path: "/item1", age: 5, size: 7),
        OnDiskCache.Item(path: "/item2", age: 3, size: 2),
        OnDiskCache.Item(path: "/item3", age: 9, size: 9)
        ])
    let outputItems =
        MaxSizeCleanupPolicy(maxSize: 10).itemsToRemove(from: inputItems)

    XCTAssertEqual(outputItems, [OnDiskCache.Item(path: "/item3", age: 9, size: 9)])
}
}

最后也总结了一下

Extract algorithms
Functional style with value types
Thin layer on top to execute effects

Scalable Test Code

Balance between UI and unit tests

Unit Test和UI Test特点不同,针对的用例也是不同

  • Unit tests great for testing small, hard-to-reach code paths
  • UI tests are better at testing integration of larger pieces

Code to help UI tests scale

从代码上注意使UI测试更具有可扩展性

Abstracting UI element queries

这是针对UI相关元素的查询,避免一条一条的列出来,可以使用变量数组的形式,使得即使有新的条目也可以轻松加进来

下面是两种写法的对比:

最后是总结了一下:

  • Store parts of queries in a variable
  • Wrap complex queries in utility methods
  • Reduces noise and clutter in UI test
Creating objects and utility functions

如下所示,所有的代码混在一起,非常难以维护

func testGameWithDifficultyBeginnerAndSoundOff() {
    app.navigationBars["Game.GameView"].buttons["Settings"].tap()
    app.buttons["Difficulty"].tap()
    app.buttons["beginner"].tap()
    app.navigationBars.buttons["Back"].tap()
    app.buttons["Sound"].tap()
    app.buttons["off"].tap()
    app.navigationBars.buttons["Back"].tap()
    app.navigationBars.buttons["Back"].tap()
    // test code
}

重构改用辅助性的对象后,代码就清晰了很多

enum Difficulty {
    case beginner
    case intermediate
    case veteran
}
enum Sound {
    case on
    case off
}

func setDifficulty(_ difficulty: Difficulty) {
    // code
}
func setSound(_ sound: Sound) {
    // code
}

func testGameWithDifficultyBeginnerAndSoundOff() {
    app.navigationBars["Game.GameView"].buttons["Settings"].tap()
    setDifficulty(.beginner)
    setSound(.off)
    app.navigationBars.buttons["Back"].tap()
    // test code
}

最后同样是总结:

  • Encapsulate common testing workflows
  • Cross-platform code sharing
  • Improves maintainability

这里提到了一个新的功能,可以将一组子Action合成一个有意义的组:

Utilizing keyboard shortcuts

使用keyboard shortcut,避免逐行的调用

Quality of test code

测试代码也是代码,对质量的要求与产品代码同样重要!

Important to consider even though it isn't shipping
Test code should support the evolution of your app
Coding principles in app code also apply to test code

测试代码同样需要代码审阅!而不是说用测试代码来代替你的代码审阅!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351

推荐阅读更多精彩内容