文章

在现有项目中添加单元测试

移除组件之间的耦合,以扩大测试覆盖面并提高可靠性。

概览

单元测试使用 XCTest 来编写,可让您确信更改和添加内容不会导致 app 功能下降,从而加快您的开发速度。在现有项目中添加单元测试可能比较难,因为如果做出的设计选择没有考虑可测试性,可能会使不同的类或子系统耦合在一起,导致无法将它们分开测试。软件设计中的耦合表现为某个类或函数只有在与以特定方式工作的其他代码连接时才能成功地使用。有时,这种耦合意味着您的测试会尝试连接网络或与文件系统交互,而这会造成测试速度减慢并让结果变得不确定。移除耦合后便可以引入单元测试,但需要您在测试还没有覆盖的位置上进行代码更改,而这可能存在风险。

通过确定您想要测试的组件,并编写能涵盖您要断言的行为的测试用例,改进您项目中的测试覆盖范围。利用以风险导向的优先级确定方法,确保测试覆盖以下两类功能中的逻辑:收到过大量用户错误报告的功能,或者性能下降有最大影响的功能。

如果您要测试的代码与项目的另一部分或某个框架类耦合,请尽可能减少对这个代码的更改,在不改变其行为的前提下隔离这个组件。在减少耦合的前提下,提升在测试环境中使用相应类的能力,并仅稍作调整,以降低与各项更改相关的风险。

以下几个部分提供了一些更改建议,介绍了在待测代码与其他组件之间的耦合妨碍测试时如何移除这些耦合。每种解决方案均展示了 XCTest 用例如何与更改后的代码配合来断言其行为。

将具体类型替换为协议

如果您的代码依赖于特定的类,而这个类的行为会导致测试困难,请创建一个协议来列出相应代码使用的方法和属性。这种问题依赖项的示例包括访问外部状态的依赖项,如用户文稿或数据库等,也包括没有确定性结果的依赖项,如网络连接或随机值生成器等。

以下摘录显示了 Cocoa app 中的一个类,该类使用 NSWorkspace (英文) 来打开作为电子邮件或即时信息附件的文件。openAttachment(file:in:) 方法的结果取决于用户有没有安装能处理所请求类型的文件的 app,以及这个 app 能不能成功打开文件。所有这些变量都有可能导致测试失败,并由于需调查的“错误”可能是与您的代码无关的瞬态问题,从而减慢您开发的速度。


import Cocoa


enum AttachmentOpeningError : Error {
  case UnableToOpenAttachment
}


class AttachmentOpener {
  func openAttachment(file location: URL, with workspace: NSWorkspace) throws {
    if (!workspace.open(location)) {
      throw AttachmentOpeningError.UnableToOpenAttachment
    }
  }
}

要测试具有这种耦合的代码,可引入一个协议来描述您的代码如何与问题依赖项交互。在您的代码中使用该协议,使得您的类依赖于相关方法在协议中的存在性,而不是它们的具体实现。为协议编写不执行有状态或非确定性任务的可选实现,并使用该实现来编写具有受控行为的测试。

以下摘录中定义了一个包含 open(_:) 方法的协议,以及一个 NSWorkspace 扩展来使得 NSWorkspace 遵从该协议。


import Cocoa


enum AttachmentOpeningError : Error {
  case UnableToOpenAttachment
}


protocol URLOpener {
  func open(_ file: URL) -> Bool
}


extension NSWorkspace : URLOpener {}


class AttachmentOpener {
  func openAttachment(file location: URL, with workspace: URLOpener) throws {
    if (!workspace.open(location)) {
      throw AttachmentOpeningError.UnableToOpenAttachment
    }
  }
}

在测试中,为 URLOpener 协议另外编写一个不依赖于用户电脑上所装 app 的实现代码。


class StubWorkspace : URLOpener {
  func open(_ file: URL) -> Bool {
    return isSuccessful
  }


  var isSuccessful: Bool = true
}


class AttachmentOpenerTests: XCTestCase {
  var workspace: StubWorkspace! = nil
  var attachmentOpener: AttachmentOpener! = nil
  let location = URL(fileURLWithPath: "/tmp/a_file.txt")


  override func setUp() {
    workspace = StubWorkspace()
    attachmentOpener = AttachmentOpener()
  }


  override func tearDown() {
    workspace = nil
    attachmentOpener = nil
  }


  func testWorkspaceCanOpenAttachment() {
    workspace.isSuccessful = true
    XCTAssertNoThrow(try attachmentOpener.openAttachment(file: location, with: workspace))
  }


  func testThrowIfWorkspaceCannotOpenAttachment() {
    workspace.isSuccessful = false
    XCTAssertThrowsError(try attachmentOpener.openAttachment(file: location, with: workspace))
  }
}

将指定类型替换为元类型值

如果 app 中的某个类会创建并使用另一个类的实例,并且创建的对象会带来测试难点,那么可能很难在创建的位置上测试该类。对所创建对象的类型进行参数化处理,并使用必要的构造器来创建实例。这种棘手测试情形的示例包括用一个控制器响应用户操作来在文件系统上创建新文稿,或者用一个方法来解释从 Web 服务收到的 JSON 并创建代表所接收数据的新 Core Data 托管对象。

在以上各种情形中,由于相关对象由您想要测试的代码创建,您无法将另一个对象作为参数传递给对应方法。该对象只有在您的代码创建它后才会存在,这时它所属的类型具有不可测试的行为。

以下摘录中显示了一个 UIDocumentBrowserViewControllerDelegate (英文),它会在用户从浏览器中选取文稿时创建并打开一个文稿对象。它创建的文稿对象会从文件系统读取数据并向文件系统写入数据,因此难以在单元测试中控制它的行为。


class DocumentBrowserDelegate : NSObject, UIDocumentBrowserViewControllerDelegate {
  func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
    guard let sourceURL = documentURLs.first else { return }
    let storyBoard = UIStoryboard(name: "Main", bundle: nil)
    let documentViewController = storyBoard.instantiateViewController(withIdentifier: "DocumentViewController") as! DocumentViewController
    documentViewController.document = Document(fileURL: sourceURL)
    documentViewController.modalPresentationStyle = .fullScreen


    controller.present(documentViewController, animated: true, completion: nil)
  }
}

要移除您要尝试测试的代码和它所创建的对象之间的耦合,请在待测类上定义一个变量来表示它应构造的对象的“类型”。这类变量称为“元类型值”。将默认值设为该类所使用的类型。您需要确保用于构造实例的构造器标记为 required。以下摘录显示了引入该变量的文稿浏览器视图控制器委托。该委托会创建类型由该元类型值定义的文稿。


class DocumentBrowserDelegate : NSObject, UIDocumentBrowserViewControllerDelegate {
  var DocumentClass = Document.self


  func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) {
    guard let sourceURL = documentURLs.first else { return }
    let storyBoard = UIStoryboard(name: "Main", bundle: nil)
    let documentViewController = storyBoard.instantiateViewController(withIdentifier: "DocumentViewController") as! DocumentViewController
    documentViewController.document = DocumentClass.init(fileURL: sourceURL)
    documentViewController.modalPresentationStyle = .fullScreen


    controller.present(documentViewController, animated: true, completion: nil)
  }
}

在测试中为该元类型设置一个不同的值,这样您的代码就能构造没有同样不可测试行为的对象。在测试中,为文稿类创建一个“试验虚拟”版本:这个类具有相同的接口,但不实现难以测试的行为。在这个用例中,虚拟文稿类不应与文件系统交互。


class DummyDocument : Document {
  static var opensSuccessfully:Bool = true
  static var savesSuccessfully:Bool = true
  static var closesSuccessfully:Bool = true


  override func save(to url: URL, for saveOperation: UIDocument.SaveOperation, completionHandler: ((Bool) -> Void)? = nil) {
    // don't save anything, just call the completion handler
    guard let handler = completionHandler else { return }
    handler(StubDocument.savesSuccessfully)
  }


  override func close(completionHandler: ((Bool) -> Void)? = nil) {
    guard let handler = completionHandler else { return }
    handler(StubDocument.closesSuccessfully)
  }


  override func open(completionHandler: ((Bool) -> Void)? = nil) {
    guard let handler = completionHandler else { return }
    handler(StubDocument.opensSuccessfully)
  }
}

在测试用例的 setUp() 方法中,将文稿类型替换为虚拟类型,使待测委托创建虚拟文稿类型的实例,这些实例在测试中具有确定的行为。


class DocumentBrowserDelegateTests: XCTestCase {
  var delegate: DocumentBrowserDelegate! = nil


  override func setUp() {
    delegate = DocumentBrowserDelegate()
    delegate.DocumentClass = StubDocument.self
  }


  override func tearDown() {
  }


  // test methods here
}

将不可测试的方法归为子类并加以覆盖

如果一个类结合运用自定逻辑与导致该类难以测试的交互或行为,请引入一个子类来覆盖该类的一部分方法,从而让其余部分变得更易测试。类设计中常常会同时包含特定于某个 app 的逻辑以及与环境或框架的交互,而后者带来的行为在测试中难以控制。一个常见的示例是 UIViewController (英文) 子类,该子类在其操作方法中包含特定于 app 的代码,同时也会载入视图或提供其他视图控制器。

我们需要对自定 app 逻辑进行测试,确保此逻辑能按预期运行并防止出现性能下降。控制或处理类与环境之间的交互比较复杂,导致相关逻辑难以测试。

例如,以下 iOS 视图控制器使用在描述文件对象中找到的用户名来填充标签 (并且大体上,它也可以使用描述文件中的其他栏位来填充其他 UI 元素)。它使用 UserDefaults (英文) 查找文件的路径,尝试将该文件载入为字典,然后使用该字典中的值来填充 UI。


struct UserProfile {
    let name: String
}


class ProfileViewController: UIViewController {
    @IBOutlet var nameLabel: UILabel!
    
    override func viewDidLoad() {
        self.loadProfile() { maybeUser in
            if let user = maybeUser {
                self.nameLabel.text = user.name
            } else {
                self.nameLabel.text = "Unknown User"
            }
        }
    }
    
    func loadProfile(_ completion: (UserProfile?) -> ()) {
        let path = UserDefaults.standard.string(forKey: "ProfilePath")
        guard let thePath = path else {
            completion(nil)
            return
        }
        let profileURL = URL(fileURLWithPath: thePath)
        let profileDict = NSDictionary(contentsOf: profileURL)
        guard let profileDictionary = profileDict else {
            completion(nil)
            return
        }
        guard let userName = profileDictionary["Name"] else {
            completion(nil)
            return
        }
        let profile = UserProfile(name: userName as! String)
        completion(profile)
    }
}


要解决这种复杂性,请将您的视图控制器归为子类,并通过用更简单的方法覆盖来“剔除”产生复杂并不可测试交互的方法。在您的测试中使用该子类来验证您没有覆盖的自定逻辑的行为。如果待测代码会创建目标类型的实例,您也可能需要引入元类型值。

以下摘录中引入了子类 StubProfileViewController,该子类与 UserDefaults 以及父类中的文件系统之间没有任何耦合。相反,它使用了由调用方配置的 UserProfile 对象。使用此子类的测试可以轻松而准确地提供所需的对象来触发要测试的逻辑。


class StubProfileViewController: ProfileViewController {
    var loadedProfile: UserProfile? = nil


    override func loadProfile(_ completion: (UserProfile?) -> ()) {
        completion(loadedProfile)
    }
}


需要两个测试才能完全覆盖 viewDidLoad() 的行为。其中一个测试在描述文件可以载入时,检查是否从描述文件中正确设置了名称。另一个测试在描述文件不能载入时,检查是否使用了名称的占位符值。


class ProfileViewControllerTests: XCTestCase {
    var profileVC: StubProfileViewController! = nil
    
    override func setUp() {
        profileVC = StubProfileViewController()
        // configure the label, in lieu of loading a storyboard
        profileVC.nameLabel = UILabel(frame: CGRect.zero)
    }


    override func tearDown() {
    }


    func testSuccessfulProfileLoadingSetsNameLabel() {
        profileVC.loadedProfile = UserProfile(name: "User Name")
        profileVC.viewDidLoad()
        XCTAssertEqual(profileVC.nameLabel.text, "User Name")
    }
    
    func testFailedProfileLoadingUsesPlaceholderLabelValue() {
        profileVC.loadedProfile = nil
        profileVC.viewDidLoad()
        XCTAssertEqual(profileVC.nameLabel.text, "Unknown User")
    }
}


注入单一实例

如果您的代码使用单一实例对象来获取对全局状态或行为的访问,请将该单一实例转换为可被替换的参数,来支持测试所需的隔离。单一实例的使用可能会分散到整个代码库中,这会导致在由您要尝试测试的组件使用时,难以知晓单一实例所处的状态。以不同的顺序运行测试可能会产生不同的结果。

常用的单一实例 (包括 NSApplication 和默认的 FileManager) 具有依赖于外部状态的行为。使用这些单一实例的组件会直接给可靠测试带来更多复杂因素。

在这个示例中,一个 Cocoa 视图控制器代表某新闻 app 中文稿检查器的一部分。当该视图控制器代表的对象发生改变时,它会发布一个通知到 app 中其他组件订阅的默认通知中心。


let InspectedArticleDidChangeNotificationName: String = "InspectedArticleDidChange"


class ArticleInspectorViewController: NSViewController {
  var article: Article! = nil


  override var representedObject: Any? {
    didSet {
      article = representedObject as! Article?
      NotificationCenter.default.post(name: NSNotification.Name(rawValue: InspectedArticleDidChangeNotificationName), object: article, userInfo: ["Article": article!])
    }
  }
}

尽管测试可以在默认通知中心中注册来观察此通知,但由于使用了单一实例通知中心,app 中的其他组件有可能会干扰测试结果。其他代码可能会发布通知,移除观察器,或运行自己的代码来响应通知,所有这些都可能会干扰测试的结果。

将对单一实例对象的直接访问替换为可从待测组件外部控制的参数或属性。在 app 中,继续将单一实例用作组件的协作者。在测试中,提供更易控制的替代对象。

以下摘录显示了将此更改应用到上述文章检查器视图控制器后的结果。该视图控制器将通知发布到其 notificationCenter 属性中定义的通知中心,该属性初始化为默认的中心。


let InspectedArticleDidChangeNotificationName: String = "InspectedArticleDidChange"


class ArticleInspectorViewController: NSViewController {
  var article: Article! = nil
  var notificationCenter: NotificationCenter = NotificationCenter.default


  override var representedObject: Any? {
    didSet {
      article = representedObject as! Article?
      notificationCenter.post(name: NSNotification.Name(rawValue: InspectedArticleDidChangeNotificationName), object: self, userInfo: ["Article": article!])
    }
  }
}

在测试用例中,您可以替换为不同的通知中心,该通知中心不在测试套件或 app 的其余部分中使用,因而与其他测试和模块的行为隔离开来。


class ArticleInspectorViewControllerTests: XCTestCase {


  var viewController: ArticleInspectorViewController! = nil
  var article: Article! = nil
  var notificationCenter: NotificationCenter! = nil
  var articleFromNotification: Article! = nil
  var observer: NSObjectProtocol? = nil


  override func setUp() {
    notificationCenter = NotificationCenter()
    viewController = ArticleInspectorViewController()
    viewController.notificationCenter = notificationCenter
    article = Article()
    observer = notificationCenter.addObserver(forName: NSNotification.Name(InspectedArticleDidChangeNotificationName), object: viewController, queue: nil) { note in
      let observedArticle = note.userInfo?["Article"]
      self.articleFromNotification = observedArticle as? Article
    }
  }


  override func tearDown() {
    notificationCenter.removeObserver(observer!)
  }


  func testNotificationSentOnChangingInspectedArticle() {
    viewController.representedObject = self.article
    XCTAssertEqual(self.articleFromNotification, self.article, "Notification should have been posted")
  }
}

您可能需要将此更改与本文“将具体类型替换为协议”和“将不可测试的方法归为子类并加以覆盖”部分中所述的更改相结合,以创建您在测试中用于取代单一实例的替代对象。当单一实例带来的行为难以在测试中控制时,如 FileManagerNSApplication 等单一实例,您就需要这样做。

另请参阅

测试