iOS开发—单元测试和UI测试教程

1,349 阅读19分钟

弄清楚要测试什么

在编写任何测试之前,了解基础知识很重要。你需要测试什么?

如果您的目标是扩展现有应用程序,您应该首先为您计划更改的任何组件编写测试。

通常,测试应涵盖:

  • 核心功能:模型类和方法及其与控制器的交互
  • 最常见的 UI 工作流程
  • 边界条件
  • Bug修复

了解测试的最佳实践

首字母缩略词FIRST描述了一组简洁的有效单元测试标准。这些标准是:

  • 快速:测试应该快速运行。
  • 独立/隔离:测试不应该相互共享状态。
  • 可重复:每次运行测试时都应该获得相同的结果。外部数据提供者或并发问题可能会导致间歇性故障。
  • 自我验证:测试应该是完全自动化的。输出应该是“通过”或“失败”,而不是依赖于程序员对日志文件的解释。
  • 及时:理想情况下,您应该在编写测试的生产代码之前编写测试。这被称为测试驱动开发。

遵循 FIRST 原则将使您的测试保持清晰和有用,而不是成为应用程序的障碍。

01.png

Xcode 中的单元测试

测试导航器提供了使用测试的最简单方法。您将使用它来创建测试目标并针对您的应用运行测试。

创建单元测试目标

打开BullsEye项目并按Command-6打开测试导航器。

单击左下角的*+* ,然后从菜单中选择New Unit Test Target... :

02.png

接受默认名称BullsEyeTests并输入com.raywenderlich作为组织标识符。当测试包出现在测试导航器中时,通过单击显示三角形将其展开,然后单击BullsEyeTests以在编辑器中打开它。

03.png

默认模板导入测试框架XCTest,并定义、with和示例测试方法的BullsEyeTests子类。XCTestCase``setUpWithError()``tearDownWithError()

您可以通过三种方式运行测试:

  1. 产品 ▸ 测试Command-U。这两个都运行所有测试类。
  2. 单击测试导航器中的箭头按钮。
  3. 单击装订线中的菱形按钮。

04.png

您还可以通过单击测试导航器或装订线中的菱形来运行单个测试方法。

尝试不同的方法来运行测试,以了解它需要多长时间以及它的外观。样本测试还没有做任何事情,所以它们运行得非常快!

当所有测试都成功后,菱形将变为绿色并显示复选标记。单击末尾的灰色菱形testPerformanceExample()打开性能结果:

05.png

本教程不需要testPerformanceExample()或不需要testExample(),因此请删除它们。

使用 XCTAssert 测试模型

首先,您将使用XCTAssert函数来测试 BullsEye 模型的核心功能:是否BullsEyeGame正确计算了一轮得分?

BullsEyeTests.swift中,在下面添加这一行import XCTest

@testable 导入BullsEye

这使单元测试可以访问 BullsEye 中的内部类型和函数。

在 的顶部BullsEyeTests,添加此属性:

var sut: BullsEyeGame! 

这将创建一个占位符BullsEyeGame,即被测系统(SUT),或此测试用例类与测试相关的对象。

接下来,将 的内容替换为setUpWithError()

尝试 超级.setUpWithError()
sut =  BullsEyeGame ()

这会BullsEyeGame在类级别创建,因此该测试类中的所有测试都可以访问 SUT 对象的属性和方法。

在你忘记之前,释放你的 SUT 对象tearDownWithError()。将其内容替换为:

sut =  nil
尝试 超级.tearDownWithError()

注意:最好在其中创建 SUTsetUpWithError()并将其释放,tearDownWithError()以确保每次测试都以干净的状态开始。如需更多讨论,请查看Jon Reid关于该主题的帖子。

编写你的第一个测试

现在您已准备好编写您的第一个测试!

将以下代码添加到末尾BullsEyeTests以测试您是否计算了猜测的预期分数:

func  testScoreIsComputedWhenGuessIsHigherThanTarget () {
   // 给
  定letguess = sut.targetValue +  5

  // 什么时候
  sut.check(猜测:猜测)

  // 然后
  XCTAssertEqual (sut.scoreRound, 95 , "从猜测计算的分数是错误的" )
}

测试方法的名称总是以test开头,后跟对其测试内容的描述。

将测试格式化为givenwhenthen部分是一种很好的做法:

  1. Given:在这里,您可以设置所需的任何值。在此示例中,您创建了一个guess值,以便您可以指定它与targetValue.
  2. 何时:在本节中,您将执行正在测试的代码:调用check(guess:)
  3. 然后: 这是您将通过在测试**失败时打印的消息断言您期望的结果的部分。在这种情况下,sut.scoreRound应该等于 95,因为它是 100 - 5。

通过单击装订线或测试导航器中的菱形图标运行测试。这将构建并运行应用程序,菱形图标将变为绿色复选标记!您还会在 Xcode 上看到一个短暂的弹出窗口,它也表示成功,如下所示:

06.png

注意:要查看**XCTestAssertions的完整列表,请转到Apple 的 Assertions Listed by Category

调试测试

有一个故意内置的错误BullsEyeGame,您现在将练习查找它。要查看实际中的错误,您将创建一个从给定部分中减去 5 的测试,并使其他**所有内容保持不变。targetValue

添加以下测试:

func  testScoreIsComputedWhenGuessIsLowerThanTarget () {
   // 给
  定letguess = sut.targetValue -  5

  // 什么时候
  sut.check(猜测:猜测)

  // 然后
  XCTAssertEqual (sut.scoreRound, 95 , "从猜测计算的分数是错误的" )
}

guess和之间的差targetValue仍然是 5,所以分数应该仍然是 95。

在 Breakpoint 导航器中,添加一个Test Failure Breakpoint。当测试方法发布失败断言时,这会停止测试运​​行。

07.png

运行你的测试,它应该在XCTAssertEqual测试失败的那一行停止。

检查sutguess在调试控制台中:

08.png

guesstargetValue − 5但是scoreRound是 105,而不是 95!

要进一步调查,请使用正常的调试过程:在when语句中设置断点,并在BullsEyeGame.swift的 inside中设置断点check(guess:),它会在其中创建difference. 然后,再次运行测试,并跳过let difference语句以检查difference应用程序中的值:

09.png

问题是difference负数,所以分数是 100 - (-5)。要解决此问题,您应该使用的绝对值differencecheck(guess:)中,取消注释正确的行并删除不正确的行。

删除两个断点并再次运行测试以确认它现在成功。

使用 XCTestExpectation 测试异步操作

现在您已经了解了如何测试模型和调试测试失败,是时候继续测试异步代码了。

BullsEyeGame用于URLSession获取一个随机数作为下一场比赛的目标。URLSession方法是异步的:它们立即返回,但直到稍后才完成运行。要测试异步方法,请使用XCTestExpectation让您的测试等待异步操作完成。

异步测试通常很慢,因此您应该将它们与更快的单元测试分开。

创建一个名为BullsEyeSlowTests的新单元测试目标。打开全新的测试类并在现有语句下方BullsEyeSlowTests导入BullsEye应用程序模块:import

@testable 导入BullsEye

该类中的所有测试都使用默认URLSession发送请求,因此sut在中声明、创建setUpWithError()和释放tearDownWithError()。为此,请将以下内容替换为BullsEyeSlowTests

var sut: URLSession!

覆盖 func  setUpWithError () throws {
   try  super .setUpWithError()
  sut =  URLSession(配置:.default)
}

覆盖 func  tearDownWithError ()抛出{
  sut =  nil
  尝试 超级.tearDownWithError()
}

接下来,添加这个异步测试:

// 异步测试:成功快,失败慢
func  testValidApiCallGetsHTTPStatusCode200 () throws {
   // 给定
  let urlString =  
    "http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1" 
  let url =  URL(字符串:urlString)!
  // 1 
  let promise = expect(description: "状态码: 200" )

  // 当
  let dataTask = sut.dataTask(with: url) { _ , response, error in 
    // then 
    if  let error = error {
       XCTFail ( "Error: \(error.localizedDescription) " )
       return 
    } else  if  let statusCode =(响应为? HTTPURLResponse)?.statusCode {
       if statusCode ==  200 {
         // 2
        promise.fulfill()
      } else {
         XCTFail ( "状态码: \(statusCode) " )
      }
    }
  }
  数据任务.resume()
  // 3
  等待(for: [promise], timeout: 5 )
}

此测试检查发送有效请求是否返回 200 状态代码。大多数代码与您在应用程序中编写的代码相同,只是添加了以下几行:

  1. 期望(描述:):返回XCTestExpectation,存储在promisedescription描述您期望发生的事情。
  2. promise.fulfill():在异步方法的完成处理程序的成功条件闭包中调用它以标记已满足期望。
  3. wait(for:timeout:):保持测试运行,直到满足所有期望或timeout间隔结束,以先发生者为准。

运行测试。如果您已连接到 Internet,则在模拟器中加载应用程序后,测试应该需要大约一秒钟才能成功。

快速失败

失败是痛苦的,但它不必永远持续下去。

要体验失败,只需将 URL 更改testValidApiCallGetsHTTPStatusCode200()为无效的 URL:

url =  URL(字符串:“http://www.randomnumberapi.com/test”)!

运行测试。它失败了,但它需要完整的超时间隔!这是因为您假设请求总是会成功,这就是您调用promise.fulfill(). 由于请求失败,它仅在超时到期时才完成。

您可以通过更改假设来改进这一点并让测试更快地失败。与其等待请求成功,不如等待异步方法的完成处理程序被调用。一旦应用程序收到来自服务器的响应(OK 或错误),就会发生这种情况,这满足了预期。然后您的测试可以检查请求是否成功。

要查看其工作原理,请创建一个新测试。

但首先,通过撤消您对url.

然后,将以下测试添加到您的类中:

func  testApiCallCompletes () throws {
   // 给定
  let urlString =  "http://www.randomnumberapi.com/test" 
  let url =  URL (string: urlString) ! 
  让promise =期望(描述:“调用完成处理程序”)
   var statusCode:Int?
  var responseError:错误?

  // 当
  let dataTask = sut.dataTask(with: url) { _ , response, error in 
    statusCode = (response as?  HTTPURLResponse ) ? .statusCode
    响应错误=错误
    promise.fulfill()
  }
  数据任务.resume()
  等待(为:[承诺],超时:5// 然后
  XCTAssertNil (responseError)
   XCTAssertEqual (statusCode, 200 )
}

关键区别在于,只需输入完成处理程序即可满足预期,而这只需要大约一秒钟的时间。如果请求失败,则then断言失败。

运行测试。现在应该大约需要一秒钟才能失败。它失败是因为请求失败,而不是因为测试运行超出timeout

修复url然后再次运行测试以确认它现在成功。

有条件地失败

在某些情况下,执行测试没有多大意义。例如,在testValidApiCallGetsHTTPStatusCode200()没有网络连接的情况下运行会发生什么?当然,它不应该通过,因为它不会收到 200 状态码。但它也不应该失败,因为它没有测试任何东西。

幸运的是,Apple 引入XCTSkip了在先决条件失败时跳过测试。在 的声明下方添加以下行sut

networkMonitor =  NetworkMonitor .shared

NetworkMonitorwraps NWPathMonitor,提供了一种方便的方式来检查网络连接。

在中,在测试的开头testValidApiCallGetsHTTPStatusCode200()添加:XCTSkipUnless

试试 XCTSkipUnless (
  networkMonitor.isReachable,
  “此测试需要网络连接。” )

XCTSkipUnless(_:_:)当没有网络可达时跳过测试。通过禁用网络连接并运行测试来检查这一点。您将在测试旁边的装订线中看到一个新图标,表示该测试既没有通过也没有失败。

10.png

再次启用您的网络连接并重新运行测试以确保它在正常情况下仍然成功。将相同的代码添加到testApiCallCompletes().

伪造对象和交互

异步测试让您确信您的代码会为异步 API 生成正确的输入。您可能还想测试您的代码在接收来自 的输入时是否正常工作URLSession,或者它是否正确更新了UserDefaults数据库或 iCloud 容器。

大多数应用程序与系统或库对象交互 - 您无法控制的对象。与这些对象交互的测试可能很慢且不可重复,违反了FIRST原则中的两个。相反,您可以通过从存根获取输入或更新模拟对象来伪造交互。

当您的代码依赖于系统或库对象时,请使用伪造。通过创建一个假对象来扮演该角色并将这个假对象注入到您的代码中来做到这一点。Jon Reid 的Dependency Injection描述了几种方法来做到这一点。

从存根伪造输入

现在,检查应用程序getRandomNumber(completion:)是否正确解析了会话下载的数据。您将BullsEyeGame使用存根数据伪造会话。

转到 Test navigator,单击*+并选择New Unit Test Class* ...。将其命名为BullsEyeFakeTests,将其保存在BullsEyeTests目录中并将目标设置为BullsEyeTests

11.png

import在语句下方导入 BullsEye 应用程序模块:

@testable 导入BullsEye

现在,将 的内容替换为BullsEyeFakeTests

var sut: BullsEyeGame!

覆盖 func  setUpWithError () throws {
   try  super .setUpWithError()
  sut =  BullsEyeGame ()
}

覆盖 func  tearDownWithError ()抛出{
  sut =  nil
  尝试 超级.tearDownWithError()
}

这声明了 SUT,即在BullsEyeGame中创建它并在 中setUpWithError()释放它tearDownWithError()

BullsEye 项目包含支持文件URLSessionStub.swift。这定义了一个名为 的简单协议,URLSessionProtocol其中包含一个创建数据任务的方法URL。它还定义了URLSessionStub, 符合此协议。它的初始化程序允许您定义数据任务应返回的数据、响应和错误。

要设置伪造,请转到BullsEyeFakeTests.swift并添加一个新测试:

func  testStartNewRoundUsesRandomValueFromApiRequest () {
   // 给定
  // 1 
  let stubbedData =  "[1]" .data(using: .utf8)
   let urlString =  
    "http://www.randomnumberapi.com/api/v1.0/random?min =0&max=100&count=1"
  让url =  URL (string: urlString) ! 
  让stubbedResponse =  HTTPURLResponse (
    网址:网址,
    状态码:200,
    http版本:无,
    headerFields: nil )
  让urlSessionStub =  URLSessionStub (
    数据:存根数据,
    响应:存根响应,
    错误:无)
  sut.urlSession = urlSessionStub
  让promise =期望(描述:“收到的价值”)

  // 什么时候
  sut.startNewRound {
    // 然后
    // 2 
    XCTAssertEqual ( self .sut.targetValue, 1 )
    promise.fulfill()
  }
  等待(为:[承诺],超时:5)
}

这个测试做了两件事:

  1. 您设置假数据和响应并创建假会话对象。最后,将假会话作为sut.
  2. 您仍然必须将其编写为异步测试,因为存根伪装成异步方法。通过与存根的假号码进行比较,检查调用是否startNewRound(completion:)解析了假数据。targetValue

运行测试。它应该很快就会成功,因为没有任何真正的网络连接!

伪造模拟对象的更新

之前的测试使用存根来提供来自假对象的输入。接下来,您将使用一个模拟对象来测试您的代码是否正确更新UserDefaults

这个应用程序有两种游戏风格。用户可以:

  1. 移动滑块以匹配目标值。
  2. 从滑块位置猜测目标值。

右下角的分段控件切换游戏风格并将其保存为UserDefaults.

您的下一个测试检查应用程序是否正确保存了该gameStyle属性。

向目标BullsEyeTests添加一个新的测试类并将其命名为BullsEyeMockTestsimport在语句下面添加以下内容:

@testable 导入BullsEye

类 MockUserDefaults : UserDefaults {
   var gameStyleChanged =  0
  覆盖 函数 集( _value  : Int , forKey defaultName : String ) {
     if defaultName == " gameStyle " {  
      游戏风格改变+=  1
    }
  }
}

MockUserDefaults覆盖set(_:forKey:)为增量gameStyleChanged。类似的测试通常会设置一个Bool变量,但递增Int为您提供了更大的灵活性。例如,您的测试可以检查应用程序是否只调用该方法一次。

接下来,在BullsEyeMockTests中声明 SUT 和模拟对象:

var sut:视图控制器!
var mockUserDefaults: MockUserDefaults!

替换setUpWithError()tearDownWithError()

覆盖 func  setUpWithError () throws {
   try  super .setUpWithError()
  sut =  UIStoryboard(名称:Main”,捆绑:nil)
    .instantiateInitialViewController()作为? ViewController 
  mockUserDefaults =  MockUserDefaults (suiteName: "testing" )
  sut.defaults = mockUserDefaults
}

覆盖 func  tearDownWithError ()抛出{
  sut =  nil 
  mockUserDefaults =  nil 
  try  super .tearDownWithError()
}

这将创建 SUT 和模拟对象,并将模拟对象作为 SUT 的属性注入。

现在,将模板中的两个默认测试方法替换为:

func  testGameStyleCanBeChanged () {
   // 给定
  let segmentedControl =  UISegmentedControl ()

  // 当
  XCTAssertEqual (
    mockUserDefaults.gameStyleChanged,
    0 , 
     "gameStyleChanged 在 sendActions 之前应该为 0" )
  分段控制.addTarget(
    苏,
    动作:#selector ( ViewController.chooseGameStyle ( _ :)),
    对于:.valueChanged)
  segmentedControl.sendActions(for: .valueChanged)

  // 然后
  XCTAssertEqual (
    mockUserDefaults.gameStyleChanged,
    1 
     “gameStyle用户默认没有改变”)
}

when断言是在测试方法改变分段控制之前gameStyleChanged标志为0 。因此,如果then断言也为真,则意味着set(_:forKey:)只调用了一次。

运行测试。它应该成功。

Xcode 中的 UI 测试

UI 测试允许您测试与用户界面的交互。UI 测试的工作原理是通过查询查找应用程序的 UI 对象,合成事件,然后将事件发送到这些对象。该 API 使您能够检查 UI 对象的属性和状态,以将它们与预期状态进行比较。

在测试导航器中,添加一个新的UI 测试目标。检查要测试的目标BullsEye,然后接受默认名称BullsEyeUITests

12.png

打开BullsEyeUITests.swift并在类的顶部添加这个属性BullsEyeUITests

var应用程序:XCUIApplication!

删除tearDownWithError()并替换setUpWithError()以下内容:

尝试 超级.setUpWithError()
continueAfterFailure =  false 
app =  XCUIApplication ()
app.launch()

删除两个现有测试并添加一个名为testGameStyleSwitch().

func  testGameStyleSwitch () {    
}

在其中打开一个新行,然后单击编辑器窗口底部的testGameStyleSwitch()红色记录按钮:

13.png

这将以将您的交互记录为测试命令的模式在模拟器中打开应用程序。应用加载后,点击游戏风格开关的Slide部分和顶部标签。再次单击 Xcode Record按钮以停止录制。

您现在有以下三行testGameStyleSwitch()

让app =  XCUIApplication ()
app.buttons[ “幻灯片” ].tap()
app.staticTexts[ "尽可能靠近:" ].tap()

记录器已创建代码来测试您在应用程序中测试的相同操作。轻按一下游戏风格的分段控件和顶部标签。您将使用这些作为基础来创建您自己的 UI 测试。如果您看到任何其他陈述,只需将其删除。

第一行复制了您在 中创建的属性setUpWithError(),因此删除该行。你还不需要点击任何东西,所以也要.tap()在第 2 行和第 3 行的末尾删除。现在,打开旁边的小菜单["Slide"]并选择segmentedControls.buttons["Slide"]

14.png

你应该留下:

app.segmentedControls.buttons[ “幻灯片” ]
app.staticTexts[ "尽可能靠近:" ]

点击任何其他对象,让记录器帮助您找到可以在测试中访问的代码。现在,用此代码替换这些行以创建给定部分:

// 给定
let slideButton = app.segmentedControls.buttons[ "Slide" ]
 let typeButton = app.segmentedControls.buttons[ "Type" ]
 let slideLabel = app.staticTexts[ "尽可能接近:" ]
 let typeLabel = app.staticTexts[ "猜猜滑块在哪里:" ]

现在您已经有了分段控件中两个按钮的名称和两个可能的顶部标签,请在下面添加以下代码:

// 然后
如果slideButton.isSelected {
   XCTAssertTrue (slideLabel.exists)
   XCTAssertFalse (typeLabel.exists)

  类型按钮.tap()
  XCTAssertTrue (typeLabel.exists)
   XCTAssertFalse (slideLabel.exists)
} else  if typeButton.isSelected {
   XCTAssertTrue (typeLabel.exists)
   XCTAssertFalse (slideLabel.exists)

  滑动按钮.tap()
  XCTAssertTrue (slideLabel.exists)
   XCTAssertFalse (typeLabel.exists)
}

tap()这将检查您在分段控件中的每个按钮上是否存在正确的标签。运行测试——所有断言都应该成功。

测试性能

来自苹果的文档

性能测试获取您想要评估的代码块并运行十次,收集平均执行时间和运行的标准偏差。这些单独测量的平均值形成了测试运行的值,然后可以将其与基线进行比较以评估成功或失败。

编写性能测试很简单:只需将要测量的代码放入measure(). 此外,您可以指定要衡量的多个指标。

将以下测试添加到BullsEyeTests

func  testScoreIsComputedPerformance () {
  措施(
    指标:[
      XCTClockMetric (), 
       XCTCPUMetric (),
       XCTStorageMetric (), 
       XCTMemoryMetric ()
    ]
  ) {
    sut.check(猜测:100)
  }
}

该测试测量多个指标:

  • XCTClockMetric测量经过的时间。
  • XCTCPUMetric跟踪 CPU 活动,包括 CPU 时间、周期和指令数。
  • XCTStorageMetric告诉您测试代码写入存储的数据量。
  • XCTMemoryMetric跟踪使用的物理内存量。

measure()运行测试,然后单击出现在尾随闭包开头旁边的图标以查看统计信息。您可以更改 Metric 旁边的选定指标

15.png

单击设置基线以设置参考时间。再次运行性能测试并查看结果——它可能比基线更好或更差。编辑按钮允许您将基线重置为这个新结果。

基线是按设备配置存储的,因此您可以在多个不同的设备上执行相同的测试。每个都可以根据特定配置的处理器速度、内存等保持不同的基线。

每当您对可能影响被测试方法性能的应用程序进行更改时,请再次运行性能测试以查看它与基线的比较情况。

启用代码覆盖率

代码覆盖率工具会告诉您测试实际运行的应用程序代码,因此您知道应用程序的哪些部分没有经过测试——至少目前还没有。

要启用代码覆盖率,请编辑方案的测试操作并选中选项选项卡下的**收集覆盖率复选框:

16.png

使用Command-U运行所有测试,然后使用Command-9打开报告导航器。在该列表的顶部项目下选择Coverage :

17.png

单击显示三角形以查看BullsEyeGame.swift中的函数和闭包列表:

18.png

滚动getRandomNumber(completion:)查看覆盖率为 95.0%。

单击此函数的箭头按钮以打开该函数的源文件。当您将鼠标悬停在右侧边栏中的覆盖注释上时,代码部分会突出显示绿色或红色:

19.png

覆盖注释显示测试命中每个代码部分的次数。未调用的部分以红色突出显示。

实现 100% 的覆盖率?

你应该多努力争取 100% 的代码覆盖率?只需谷歌“100% 单元测试覆盖率”,您就会发现支持和反对这一点的一系列论据,以及关于“100% 覆盖率”定义的争论。反对它的论点说最后 10%–15% 不值得努力。它的论据说最后 10%–15% 是最重要的,因为它很难测试。谷歌“难以对糟糕的设计进行单元测试”以找到有说服力的论点,即不可测试的代码是更深层次设计问题的标志

本教程的最终项目资料下载地址

链接:pan.baidu.com/s/1OyFTBucz…

提取码:17da

这里也推荐一些面试相关的内容!