首页 iOS.& Swift Books iOS通过教程进行测试驱动

13
分手依赖性 由Michael Katz撰写

当您已经测试到位时,更改更加安全。但是,在没有现有测试的情况下,您可能需要更改以添加测试!这是一个最常见的原因是紧密耦合的依赖关系:您无法为类添加测试,因为它取决于依赖其他类的其他类...查看控制器尤其是此问题的受害者。

通过在最后一章中创建依赖项映射,您可以找到要在哪里进行更改的位置,然后依赖于您真正需要进行测试。

本章将教您如何安全地打破依赖项,以在您要更改的位置添加测试。

入门

作为提醒,在本章中,您将建立并完善 mybiz 应用程序。想要构建单独的费用报告应用程序的权力。为了干燥(不要重复自己),他们希望在新应用程序中重用从应用程序中的登录视图。这样做的最佳方法是将登录功能拉到自己的框架中,因此可以在项目中重复使用。

登录视图控制器是显而易见的地方,因为它呈现登录UI并使用与登录相关的所有其他代码。在上一章中,您构建了登录视图控制器的依赖性映射,并标识了一些更改点。您将使用该地图作为分解依赖项的指南,因此登录可以单独站立。

表征系统

Before moving any code, you want to make sure that the refactors won’t disturb the behavior of the app. To do that, start with a characterization test for the signIn(_:) function of LoginViewController. This is the main entry point for signing into the app and it’s crucial that it continues to work.

import XCTest
@testable import MyBiz

class LoginViewControllerTests: XCTestCase {

  var sut: LoginViewController!

  // 1
  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "login")
      as? LoginViewController
    UIApplication.appDelegate.userId = nil

    sut.loadViewIfNeeded()
  }

  // 2
  override func tearDown() {
    sut = nil
    UIApplication.appDelegate.userId = nil //do the "logout"
    super.tearDown()
  }

  func testSignIn_WithGoodCredentials_doesLogin() {
    // given
    sut.emailField.text = "[email protected]"
    sut.passwordField.text = "hailHydra"

    // when
    // 3
    let exp = expectation(for: NSPredicate(block:
    { vc, _ -> Bool in
      return UIApplication.appDelegate.userId != nil
    }), evaluatedWith: sut, handler: nil)

    sut.signIn(sut.signInButton!)

    // then
    // 4
    wait(for: [exp], timeout: 1)
    XCTAssertNotNil(UIApplication.appDelegate.userId,
                    "a successful login sets valid user id")
  }
}
func testSignIn_WithBadCredentials_showsError() {
  // given
  sut.emailField.text = "[email protected]"
  sut.passwordField.text = "Shazam!"

  // when
  let exp = expectation(for: NSPredicate(block:
  { vc, _ -> Bool in
    return UIApplication.appDelegate.window?.rootViewController?
      .presentedViewController != nil
  }), evaluatedWith: sut, handler: nil)

  sut.signIn(sut.signInButton!)

  // then
  wait(for: [exp], timeout: 1)
  let presentedController = UIApplication.appDelegate.window?
    .rootViewController?.presentedViewController
    as? ErrorViewController
    XCTAssertNotNil(presentedController,
                    "should be showing an error controller")
    XCTAssertEqual(presentedController?.alertTitle,
                   "Login Failed")
    XCTAssertEqual(presentedController?.subtitle,
                   "User has not been authenticated.")
}

分解API / AppDelegate依赖项

现在有 一些 tests in place, it’s time to start breaking up the dependencies so you can move the code. Starting with the API <-> AppDelegate interdependency will make it easier to break up those classes from LoginViewController later.

init(server: String) {
  self.server = server
  session = URLSession(configuration: .default)
}
let server: String
api = API(server: AppDelegate.configuration.server)
init() {
  super.init(server: "http://mockserver")
}

使用通信通知

The next step is to fix the logout() dependency. This method calls back to app delegate, but handling the post-logout state shouldn’t really live with an app delegate. You’ll use a Notification to pass the event in a general way. You won’t fix AppDelegate this time around, but you will make API ignorant of which class cares about it.

let UserLoggedOutNotification =
  Notification.Name("user logged out")
import XCTest
@testable import MyBiz

class APITests: XCTestCase {

  var sut: API!

  // 1
  override func setUp() {
    super.setUp()
    sut = MockAPI()
  }

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  // 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)
  }
}
func logout() {
  token = nil
  delegate = nil
  let note = Notification(name: UserLoggedOutNotification)
  NotificationCenter.default.post(note)
}
func setupListeners() {
  NotificationCenter.default
    .addObserver(forName: UserLoggedOutNotification,
                 object: nil,
                 queue: .main) { _ in
    self.showLogin()
  }
}
setupListeners()

反思分手

这项练习说明了拆下两个物体的两种方式:

打破AppDelegate依赖

The next stop on the dependency-detangling train is removing AppDelegate from LoginViewController.

注入API.

loginviewcontroller.swift., change the api variable to:

var api: API!
let loginViewController = window?.rootViewController as? LoginViewController
loginViewController?.api = api
loginController?.api = api
sut.api = UIApplication.appDelegate.api

贬低登录成功

If you look at loginSucceeded(userId:) on the LoginViewController, you’ll see that none of its contents really belong in the view controller — all of the work happens on the AppDelegate! The issue then becomes how to indirectly link the API action to a consequence in the AppDelegate. Well… last time you used a Notification and you can do so again.

let UserLoggedInNotification =
  Notification.Name("user logged in")
enum UserNotificationKey: String {
  case userId
}
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")
}
let note = Notification(name: UserLoggedInNotification,
                        object: self,
                        userInfo: [UserNotificationKey.userId:
                          token.userID.uuidString])
NotificationCenter.default.post(note)
override func login(username: String, password: String) {
  let token = Token(token: username, userID: UUID())
  handleToken(token: token)
}
func handleLogin(userId: String) {
  self.userId = userId

  let storyboard = UIStoryboard(name: "Main", bundle: nil)
  let tabController =
    storyboard.instantiateViewController(
      withIdentifier: "tabController")
  window?.rootViewController = tabController
}
NotificationCenter.default
  .addObserver(
    forName: UserLoggedInNotification,
    object: nil,
    queue: .main) { note in
      if let userId =
        note.userInfo?[UserNotificationKey.userId] as? String {
          self.handleLogin(userId: userId)
      }
}

打破ErrorViewController依赖项

Looking at the dependency map for red lines, it next makes sense to tackle the dependency on LoginViewController from ErrorViewController.

import XCTest
@testable import MyBiz

class ErrorViewControllerTests: XCTestCase {

  var sut: ErrorViewController!

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

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  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)
  }
}

从错误处理中删除登录

Now that you’ve got the base behavior covered, you’re ready to go ahead and start breaking out the dependency. ErrorViewController has a try again functionality that calls back into the LoginViewController. This not only violates SOLID principles but it’s cumbersome to add this try again functionality to other screens since you’ll need to add to several switch statements and further tie in dependencies.

struct SecondaryAction {
  let title: String
  let action: () -> ()
}
import XCTest
@testable import MyBiz

import XCTest

class ErrorViewControllerTests: XCTestCase {

  var sut: ErrorViewController!

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

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  func testSecondaryButton_whenActionSet_hasCorrectTitle() {
    // given
    let action = ErrorViewController.SecondaryAction(
                   title: "title") {}
    sut.secondaryAction = action

    // when
    sut.loadViewIfNeeded()

    // then
    XCTAssertEqual(sut.secondaryButton.currentTitle, "title")
  }

  func testSecondaryAction_whenButtonTapped_isInvoked() {
    // given
    let exp = expectation(description: "secondary action")
    var actionHappened = false
    let action = ErrorViewController.SecondaryAction(
                   title: "action") {
      actionHappened = true
      exp.fulfill()
    }
    sut.secondaryAction = action
    sut.loadViewIfNeeded()

    // when
    sut.secondaryAction(())

    // then
    wait(for: [exp], timeout: 1)
    XCTAssertTrue(actionHappened)
  }
}
var secondaryAction: SecondaryAction? = nil
private func updateAction() {
  guard let action = secondaryAction else {
    secondaryButton.removeFromSuperview()
    return
  }
  secondaryButton.setTitle(action.title, for: .normal)
}
向上dateAction()
if let action = secondaryAction {
  dismiss(animated: true)
  action.action()
} else {
  Logger.logFatal("no action defined.")
}
func showAlert(title: String,
       subtitle: String?,
       action: ErrorViewController.SecondaryAction? = nil,
       skin: Skin? = nil) {
alertController.type = type
alertController.secondaryAction = action
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)
}
sut.secondaryAction = .init(title: "Try Again", action: {})

挑战

This chapter’s challenge is a simple one. You may have noticed that input validation was left out of the LoginViewControllerTests characterization tests. Your challenge is to add them now, so you will have a more robust test suite before moving the code into its own module in the next chapter. For an additional challenge, add unit tests for the Validators functions in mybizTests.

关键点

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

然后去哪儿?

转到下一章继续执行此重构项目以分解依赖项。在该章节中,您将创建一个新框架,以便登录可以以其自己的可重用模块生活。

有一个技术问题?想报告一个错误吗? 您可以向官方书籍论坛中的书籍作者提出问题和报告错误 这里.

有反馈分享在线阅读体验吗? 如果您有关于UI,UX,突出显示或我们在线阅读器的其他功能的反馈,您可以将其发送到设计团队,其中表格如下所示:

© 2021 Razeware LLC

您可以免费读取,本章的部分显示为 混淆了 文本。解锁这本书,以及我们整个书籍和视频目录,带有Raywenderlich.com的专业订阅。

现在解锁

要突出或记笔记,您需要在订阅中拥有这本书或自行购买。