跳转至

13: 打破依赖关系

当你已经有测试的时候,做一个改变总是比较安全的。然而,在没有现有测试的情况下,你可能需要做一些改变来增加测试!这是最常见的原因。最常见的原因之一是紧密耦合的依赖关系。你不能给一个类添加测试,因为它依赖于其他类,而其他类又依赖于其他类...... 特别是视图控制器经常是这个问题的受害者。

通过在上一章中创建一个依赖关系图,你能够找到你想做改变的地方,反过来,你真正需要测试的地方。

这一章将教你如何安全地打破依赖关系,在你想改变的地方添加测试。

开始工作

作为提醒,在本章中,你将在MyBiz应用程序的基础上进行改进。当局希望建立一个独立的费用报告应用程序。出于DRY(Don't Repeat Yourself)的考虑,他们想在新的应用中重新使用你的应用中的登录视图。最好的办法是把登录功能拉到自己的框架中,这样它就可以在不同的项目中重复使用。

登录视图控制器是一个明显的开始,因为它展示了登录用户界面,并使用了所有与登录有关的其他代码。在上一章中,你为登录视图控制器建立了一个依赖关系图,并确定了一些变化点。你将使用该地图作为指导,打破依赖关系,使登录可以独立存在。

image

确定系统的特征

在移动任何代码之前,你要确保重构不会干扰应用程序的行为。要做到这一点,首先要对LoginViewControllersignIn(_:)函数进行特性化测试。这是登录应用程序的主要入口,它能否继续工作是至关重要的。

CharacterizationTests ▸ Cases中添加一个新的单元测试案例类文件,名为LoginViewControllerTests.swift

将该文件的内容替换为以下内容:

import XCTest
@testable import MyBiz

class LoginViewControllerTests: XCTestCase {
    var sut: LoginViewController!
    // 1
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = UIStoryboard(name: "Main", bundle: nil)
            .instantiateViewController(withIdentifier: "login")
        as? LoginViewController
        UIApplication.appDelegate.userId = nil
        sut.loadViewIfNeeded()
    }
    // 2
    override func tearDownWithError() throws {
        sut = nil
        UIApplication.appDelegate.userId = nil //do the "logout"
        try super.tearDownWithError()
    }

    func testSignIn_WithGoodCredentials_doesLogin() {
        // given
        sut.emailField.text = "agent@shield.org"
        sut.passwordField.text = "hailHydra"
        // when
        // 3
        let predicate = NSPredicate { _, _ -> Bool in
            UIApplication.appDelegate.userId != nil
        }
        let exp = expectation(
            for: predicate,
            evaluatedWith: sut,
            handler: nil)
        sut.signIn(sut.signInButton!)
        // then
        // 4
        wait(for: [exp], timeout: 5)
        XCTAssertNotNil(
            UIApplication.appDelegate.userId,
            "a successful login sets valid user id")
    }
}

这段代码以下列方式处理基本的签到情况:

  1. setUpWithError()中,从主故事板中创建sut并加载它。它也从AppDelegate中清除了共享的userId。它是"正在登录"状态的一个代理。因为App delegate是跨测试的,所以清除它是很重要的,这样每个测试开始时都是没有登录的。
  2. tearDownWithError()中,清除userId状态是很重要的,以防其他测试在设置时没有清除它。
  3. 在测试本身中,这个谓词期望等待userId状态被设置,以实现期望。这样,测试就知道它可以安全地进行。
  4. 该测试等待userId被设置,然后断言它不是nil。尽管期望值也会因相同的条件而超时,但有一个明确的断言总是好的--而不是用超时来捕捉错误。

记住在运行这个测试之前要启动后端--否则会失败! 这个测试需要实时响应。你还没有打破它对真正的后端实现的依赖性。关于设置和启动MyBiz后台的说明,见第11章,"遗留问题"。

构建和测试 - 测试通过 这个例子缩短了特征化测试的发现部分,如第11章"遗留问题"中所述。这是过程中的一个重要部分,但不在本章的范围内。

接下来,在测试中捕捉主要的错误情况。这个向用户显示无效的登录响应的流程是视图控制器的一个重要功能。这也有助于涵盖后面的ErrorViewController的解构。

还是在LoginViewControllerTests.swift中,添加以下测试:

func testSignIn_WithBadCredentials_showsError() {
    // given
    sut.emailField.text = "bad@credentials.ca"
    sut.passwordField.text = "Shazam!"
    // when
    let predicate = NSPredicate { _, _ -> Bool in
        UIApplication.appDelegate
            .rootController?.presentedViewController != nil
    }
    let exp = expectation(
        for: predicate,
        evaluatedWith: sut,
        handler: nil)
    sut.signIn(sut.signInButton!)
    // then
    wait(for: [exp], timeout: 5)
    let presentedController =
    UIApplication.appDelegate
        .rootController?
        .presentedViewController
    as? ErrorViewController
    XCTAssertNotNil(
        presentedController,
        "should be showing an error controller")
    XCTAssertEqual(
        presentedController?.alertTitle,
        "Login Failed")
    XCTAssertEqual(
        presentedController?.subtitle,
        "Unauthorized")
}
  • given部分设置了无效的凭证。
  • when部分创建了一个期望,等待一个模态视图的显示,应该是错误视图。
  • when部分,在等待期望后,检查模态是否是一个ErrorViewController,以及alertTitlesubtitle是否与预期的不良凭证响应一致。

这些条件很好,因为它们测试的是一个特定的错误,而不是一个破碎的网络连接。然而,这个测试是相当脆弱的,并且依赖于服务器端的文本。

建立和测试。然而,这将再次通过,你已经通过这个视图控制器覆盖了两个主要的(现有)流程。作为一个挑战,为验证器的条件(坏的电子邮件和密码)也写测试。

打破API/AppDelegate的依赖关系

现在有了一些测试,是时候开始打破依赖关系了,这样你就可以移动代码了。从API <-> AppDelegate的相互依赖关系开始,将使以后从LoginViewController中拆分这些类更加容易。

你可以使用Swift的严格类型系统,在移除依赖关系时更容易。例如,进入API.swift,在文件中搜索AppDelegate的用法。

AppDelegate的第一个用法是创建服务器常量。这一点处理起来很简单。你只需把它从自动设置转移到一个init参数。

init方法替换为:

init(server: String) {
    self.server = server
    session = URLSession(configuration: .default)
}

接下来,用以下内容更新let server =这一行:

let server: String

如果你构建了这个应用程序,编译器会告诉你接下来需要做什么。进入AppDelegate.swift,将API的实例化替换为:

api = API(server: AppDelegate.configuration.server)

回到测试中,打开MockAPI.swift,并在MockAPI类中添加以下init方法:

init() {
    super.init(server: "http://mockserver")
}

构建和测试,测试仍然会通过。这是一个简单的动作,所以除了已经存在的测试之外,不需要任何额外的测试。

使用通知进行通信

下一步是修复注销的依赖性。这个方法回调到应用委托,但处理注销后的状态不应该真的由一个应用委托来处理。你将使用一个Notification来传递事件的一般方式。这次你不会修复AppDelegate,但你会让API不知道哪个类在关心它。

API.swift的顶部,就在导入语句的后面,添加:

let userLoggedOutNotification = Notification.Name("user logged out")

这将创建一个新的通知,通知应用程序的其他部分用户已经注销。

在继续进行之前,是时候创建一些测试了 在MyBizTests ▸ Cases目标中创建一个名为APITests的新单元测试类。

用以下内容替换文件的内容:

import XCTest
@testable import MyBiz

class APITests: XCTestCase {
    var sut: API!
    // 1
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = MockAPI()
    }

    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }

    // 2
    func givenLoggedIn() {
        sut.token = Token(token: "Nobody", userID: UUID())
    }

    // 3
    func testAPI_whenLogout_generatesANotification() {
        // given
        givenLoggedIn()
        let exp = expectation(
            forNotification: userLoggedOutNotification,
            object: nil)
        // when
        sut.logout()
        // then
        wait(for: [exp], timeout: 1)
        XCTAssertNil(sut.token)
    }
}
  1. 这个测试设置了一个API作为被测试的系统。这是一个MockAPI,因为被测试的方法是继承自API的,所以它是可以的。这种短期的妥协是可以的,因为它的修改行为并不是突破LoginViewController的工作的一部分。
  2. 有一个辅助方法givenLoggedIn设置一个假的token来模拟SUT的"登录"状态。
  3. 测试本身非常简单,调用logout并等待UserLoggedOutNotification。该测试还断言,令牌被重置为零。

运行测试。你会看到,这个新的测试还没有通过。为了让测试通过,打开API.swift,用以下内容替换整个logout方法:

func logout() {
    token = nil
    delegate = nil
    let note = Notification(name: userLoggedOutNotification)
    NotificationCenter.default.post(note)
}

这不是直接回调到AppDelegate,而是通过通知中心间接调用该代码。现在,API不再直接依赖委托了。然而,为了保持应用程序的运行,你需要做以下修改。

打开AppDelegate.swift,并在类的末尾添加以下方法:

func setupListeners() {
    NotificationCenter.default
        .addObserver(
            forName: userLoggedOutNotification,
            object: nil,
            queue: .main) { _ in
                self.showLogin()
            }
}

这就为新的通知添加了一个监听器。然后,在application(_:didFinishLaunchingWithOptions:)中,在返回语句前调用它,添加以下一行代码:

setupListeners()

构建并再次测试。你也可以建立和运行,然后可以通过一个完整的登录/注销周期,看看所有的东西都还能工作。

反思分离的问题

这个练习说明了两种拆分两个对象的方法:

  1. 在实例化时配置对象。API现在在初始化时就设置了它的服务器URL,而不是在以后调用一个单例。
  2. 用事件代替直接调用。注销事件通过Notification传播,而不是硬编码的回调。

在注销中,对AppDelegate的调用被发布一个Notification所取代。作为一个iOS开发者,你有很多选择来发送异步事件。NotificationCenter是最简单的,因为它自带Foundation。你也可以使用RxSwiftCombine发送信号,一个自定义的事件总线或管理一个自定义委托的列表。

进一步重构以划分责任的方法是将用户状态管理从API中提取出来。这将允许你把API作为一个通往后台的无状态网关,而用户状态管理器将能够位于用户界面和登录/注销之间。

这里使用的另一种技术是将配置传递给init方法。在这里,所需要的只是服务器的基本URL,没有任何功能上的理由要返回到AppDelegate。事实上,API不再依赖于任何UI代码。你可以继续前进,并从文件顶部删除import UIKit一行。现在,API可以被用在各种建立在相同API基础上的其他应用程序中

你可以更新依赖关系图,用一点白颜色来反映APIAppDelegate中获得的新自由。

image

打破AppDelegate的依赖关系

剥离依赖关系的下一站是将AppDelegateLoginViewController中移除。

注入API

LoginViewController.swift中,将api变量改为:

var api: API!

现在,可以在类的外部设置api,而不是直接依赖AppDelegate

Note

对于大多数类来说,使用let并通过init注入值是正确的做法。对于视图控制器来说,注入必须在实例化之后进行,通常是在prepare(for:sender:)中进行,并有一个segue,或者在演示之前通过代码完成,正如你将在下面看到的。

为了使应用程序仍然工作,你必须在几个地方设置api变量。在SceneDelegate.swift中,添加以下方法application(_:didFinishLaunchingWithOptions:)

func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    let loginViewController = UIApplication.appDelegate.rootController as? LoginViewController
    loginViewController?.api = UIApplication.appDelegate.api
}

当应用程序,或者更正确地说,窗口场景第一次被加载时,这将设置该api。在这一点上,登录控制器将被实例化,但尚未加载其视图。

接下来,改变showLogin(),在设置rootViewController之前立即添加以下一行:

loginController?.api = api

最后,由于已经有一个测试涵盖了视图控制器,你需要更新测试类。在LoginViewControllerTests.swift中,添加到setUpWithError()的底部,就在sut.loadViewIfNeeded()上面:

sut.api = UIApplication.appDelegate.api

如果你构建并运行或测试,即使你破坏了一个依赖关系,应用程序也应该继续像以前那样运行。

解除登录成功的束缚

如果你看一下LoginViewController上的loginSucceeded(userId:),你会发现它的内容都不属于视图控制器--所有的工作都发生在AppDelegate上。那么问题来了,如何间接地将API动作与AppDelegate中的结果联系起来。好吧......上次你用了一个Notification,这次你也可以这样做。

API.swift中添加以下代码,就在导入语句的下面:

let userLoggedInNotification = Notification.Name("user logged in")

enum UserNotificationKey: String {
    case userId
}

这增加了一个新的登录通知和一个用于获取用户ID的密钥。

在修改更多的代码之前,在APITests.swift中为该通知添加以下测试:

func testAPI_whenLogin_generatesANotification() {
    // given
    var userInfo: [AnyHashable: Any]?
    let exp = expectation(
        forNotification: userLoggedInNotification,
        object: nil) { note in
            userInfo = note.userInfo
            return true
        }
    // when
    sut.login(username: "test", password: "test")
    // then
    wait(for: [exp], timeout: 1)
    let userId = userInfo?[UserNotificationKey.userId]
    XCTAssertNotNil(
        userId,
        "the login notification should also have a user id")
}

这个测试调用login(username:password:)并等待通知,并检查通知的userInfo中是否有userId。运行你的测试,你会发现这个测试还不能通过。

为了让这个测试通过,打开API.swift,在handleToken(token:)中,调用loginSucceeded(userId:)之前加入以下内容:

let note = Notification(
    name: userLoggedInNotification,
    object: self,
    userInfo: [
        UserNotificationKey.userId: token.user.id.uuidString
    ])
NotificationCenter.default.post(note)

这段代码将发布通知。为了确保它在测试中被调用,在MockAPI.swift中添加以下覆盖:

override func login(username: String, password: String) {
    let token = Token(token: username, userID: UUID())
    handleToken(token: token)
}

现在,测试将建立并通过! 但你还没有完成。你现在需要将登录功能从LoginViewController转移到AppDelegate

AppDelegate.swift中添加以下辅助函数:

func handleLogin(userId: String) {
    self.userId = userId
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    let tabController =
    storyboard.instantiateViewController(withIdentifier: "tabController")
    rootController = tabController
}

这与loginSucceeded(userId:)回调的逻辑相同。接下来,在setupListeners中添加以下内容:

NotificationCenter.default
    .addObserver(
        forName: userLoggedInNotification,
        object: nil,
        queue: .main) { note in
            if let userId =
                note.userInfo?[UserNotificationKey.userId] as? String {
                self.handleLogin(userId: userId)
            }
        }

这就增加了通知的监听器。最后,在LoginViewController.swift中,将loginSucceeded(userId:)的内容替换为一个空体。

如果你建立并测试,应用程序仍然会有相同的登录/注销功能--即使现在登录的事件链有些不同。

现在你可以再次更新依赖关系图了:

image

打破对ErrorViewController的依赖

看一下红线的依赖关系图,接下来就可以解决ErrorViewControllerLoginViewController的依赖关系了。

是时候添加特性化测试了。在CharacterizationTests/Cases中创建一个新的单元测试案例类文件,名为ErrorViewControllerTests.swift,并将其内容替换为以下内容:

import XCTest
@testable import MyBiz

class ErrorViewControllerTests: XCTestCase {
    var sut: ErrorViewController!

    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = UIStoryboard(name: "Main", bundle: nil)
            .instantiateViewController(withIdentifier: "error")
        as? ErrorViewController
    }

    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }

    func whenDefault() {
        sut.type = .general
        sut.loadViewIfNeeded()
    }

    func whenSetToLogin() {
        sut.type = .login
        sut.loadViewIfNeeded()
    }

    func testViewController_whenSetToLogin_primaryButtonIsOK() {
        // when
        whenSetToLogin()
        // then
        XCTAssertEqual(sut.okButton.currentTitle, "OK")
    }

    func testViewController_whenSetToLogin_showsTryAgainButton() {
        // when
        whenSetToLogin()
        // then
        XCTAssertFalse(sut.secondaryButton.isHidden)
        XCTAssertEqual(
            sut.secondaryButton.currentTitle,
            "Try Again")
    }

    func testViewController_whenDefault_secondaryButtonIsHidden() {
        // when
        whenDefault()
        // then
        XCTAssertNil(sut.secondaryButton.superview)
    }
}

这为错误视图控制器中每个按钮的状态增加了三个简单的测试:

  • testViewController_whenSetToLogin_primaryButtonIsOK确保主按钮的标题是OK
  • testViewController_whenSetToLogin_showsTryAgainButton确保二级按钮的标题是Try Again
  • testViewController_whenDefault_secondaryButtonIsHidden确保在默认情况下,或一般情况下,没有二级按钮。

运行这些测试,观察它们是否都通过了。

理想情况下,还应该有一个针对二级按钮的测试,它实际上会产生一个重试的动作。不幸的是,在目前的状态下,由于这个类与LoginViewController交织在一起,所以很难编写单元测试。事实上,这也是打破依赖关系的主要动机之一。

要在当前状态下写一个测试,你必须对应用程序的很大一部分进行编写,以使ErrorViewController被正确设置,并带来相当数量的整体状态,以检查点击按钮时是否有效果。所以,现在先别管它。你会捕捉到再次尝试的行为,作为打破依赖关系的一部分。

从错误处理中删除登录

现在你已经有了基本的行为,你可以开始打破依赖关系了。ErrorViewController有一个"再试一次"的函数,它回调到LoginViewController。这不仅违反了SOLID原则,而且在其他屏幕上添加这个重试功能也很麻烦,因为你需要添加几个开关语句并进一步绑定依赖关系。

打破这种依赖关系的方法是使用命令模式的一种形式。也就是说,你要向视图控制器提供必要的视图信息和行为,这样按钮就可以在运行时调用重试行为。这种模式是一个对象为另一个对象提供实现的方式。

你将通过在ErrorViewController类的顶部添加以下结构来完成这个任务,上面的enum AlertType

struct SecondaryAction {
    let title: String
    let action: () -> Void
}

这个结构包含视图信息--TitleAction闭包。这就是视图控制器今后配置错误视图的方式。

MyBizTests目标中创建一个新的单元测试案例类,名为ErrorViewControllerTests.swift。然后将其内容替换为以下内容:

import XCTest
@testable import MyBiz

class ErrorViewControllerTests: XCTestCase {
    var sut: ErrorViewController!

    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = UIStoryboard(name: "Main", bundle: nil)
            .instantiateViewController(withIdentifier: "error")
        as? ErrorViewController
    }

    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }

    func whenDefault() {
        sut.type = .general
        sut.loadViewIfNeeded()
    }

    func whenSetToLogin() {
        sut.type = .login
        sut.loadViewIfNeeded()
    }

    func testViewController_whenSetToLogin_primaryButtonIsOK() {
        // when
        whenSetToLogin()

        // then
        XCTAssertEqual(sut.okButton.currentTitle, "OK")
    }

    func testViewController_whenSetToLogin_showsTryAgainButton() {
        // when
        whenSetToLogin()

        // then
        XCTAssertFalse(sut.secondaryButton.isHidden)
        XCTAssertEqual(
            sut.secondaryButton.currentTitle,
            "Try Again")
    }

    func testViewController_whenDefault_secondaryButtonIsHidden() {
        // when
        whenDefault()

        // then
        XCTAssertNil(sut.secondaryButton.superview)
    }
}

这个测试遵循与其他视图控制器测试相同的模式。有两个测试案例:testSecondaryButton_whenActionSet_hasCorrectTitletestSecondaryAction_whenButtonTapped_isInvoked。这些测试只包括使用SecondaryAction的新功能。第一个测试按钮的标题是否被适当地设置。第二项测试轻击按钮是否执行了动作块。

当然,这个测试还不能编译,更不用说运行了。现在,回到ErrorViewController.swift中。

删除AlertType枚举。然后,将类型变量替换为:

var secondaryAction: SecondaryAction?

这个属性允许你存储可选的动作。然后添加这个辅助方法:

private func updateAction() {
    guard let action = secondaryAction else {
        secondaryButton.removeFromSuperview()
        return
    }
    secondaryButton.setTitle(action.title, for: .normal)
}

要使用它,在viewDidLoad中,将switch type {...}语句替换为:

updateAction()

现在,当视图被加载时,它将调用辅助方法来设置按钮。

同时,删除setupLogin()方法。然后,将secondaryAction(_:)的主体替换为:

if let action = secondaryAction {
    dismiss(animated: true)
    action.action()
} else {
    Logger.logFatal("no action defined.")
}

这就把对LoginViewController的调用替换为对动作块的简单调用。

现在,如果你试图构建这个项目,你会看到一个编译器错误。要开始修复它,请导航到UIViewController+Alert.swift。更新showAlert(title:subtitle:type:skin:)函数签名:

func showAlert(
    title: String,
    subtitle: String?,
    action: ErrorViewController.SecondaryAction? = nil,
    skin: Skin? = nil
){

这样就把警报更新为采取一个行动而不是一个类型。

接下来,替换:

alertController.type = type

为以下内容:

alertController.secondaryAction = action

接下来,在LoginViewController.swift中,将loginFailed(error:)改为:

func loginFailed(error: Error) {
    let retryAction = ErrorViewController.SecondaryAction(
        title: "Try Again") { [weak self] in
            if let self = self {
                self.signIn(self)
            }
        }
    showAlert(
        title: "Login Failed",
        subtitle: error.localizedDescription,
        action: retryAction,
        skin: .loginAlert)
}

这个更新的方法使用新的showAlert签名来使用新的动作而不是类型。

最后,为了完成重构,请浏览ErrorViewControllerTests.swift并做如下修改。

首先,在whenDefault()中,删除sut.type = .general一行。

然后,在whenSetToLogin中,将sut.type = .login一行改为:

sut.secondaryAction = .init(title: "Try Again") {}

构建和测试,你的测试会编译并通过。这意味着ErrorViewController已经从LoginViewController中解放出来,你可以继续创建一个独立的登录模块了!

看一下你更新的依赖关系图。现在红色的部分少了很多:

image

挑战

本章的挑战是一个简单的问题。你可能已经注意到,LoginViewControllerTests的特性化测试中没有输入验证。你的挑战是现在就添加这些测试,这样在下一章将代码移到自己的模块中之前,你将拥有一个更强大的测试套件。对于一个额外的挑战,为MyBizTests中的Validators函数添加单元测试。

关键点

  • 依赖关系图是你打破依赖关系的指南。
  • 使用依赖反转、命令模式、通知和从外部配置对象等技术,一次一次地打破坏的依赖关系。
  • 在大型重构之前、期间和之后写测试。

从这里开始,要去哪里?

进入下一章,继续这个重构项目,打破依赖关系。在那一章中,你将创建一个新的框架,这样Login就可以生活在它自己的、可重用的模块中。

也值得重温一下关于网络的第3节。这一节所讲的技术将有助于解释如何修复LoginViewControllerTests,这样你就可以分解API并测试其方法,而不必使用MockAPI类。