【问题标题】:Unit Test fatalError in SwiftSwift 中的单元测试致命错误
【发布时间】:2015-12-28 15:49:49
【问题描述】:

如何在 Swift 中实现fatalError 代码路径的单元测试?

例如,我有以下 swift 代码

func divide(x: Float, by y: Float) -> Float {

    guard y != 0 else {
        fatalError("Zero division")
    }

    return x / y
}

我想在 y = 0 时对案例进行单元测试。

注意,我想使用 fatalError 而不是任何其他断言函数。

【问题讨论】:

  • "我想在 y = 0 时对案例进行单元测试。" -- 然后删除guard。但是你会得到一个运行时错误

标签: ios swift unit-testing fatal-error xctest


【解决方案1】:

SWIFT 5、4

此版本不会在每次调用 expectFatalError 时在 GCD 中留下丢弃的线程。这通过使用 Thread 而不是 DispatchQueue 来解决。感谢@jedwidz

import Foundation

// overrides Swift global `fatalError`
func fatalError(_ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line) -> Never {
    FatalErrorUtil.fatalErrorClosure(message(), file, line)
}

/// Utility functions that can replace and restore the `fatalError` global function.
enum FatalErrorUtil {
    typealias FatalErrorClosureType = (String, StaticString, UInt) -> Never
    // Called by the custom implementation of `fatalError`.
    static var fatalErrorClosure: FatalErrorClosureType = defaultFatalErrorClosure
    
    // backup of the original Swift `fatalError`
    private static let defaultFatalErrorClosure: FatalErrorClosureType = { Swift.fatalError($0, file: $1, line: $2) }
    
    /// Replace the `fatalError` global function with something else.
    static func replaceFatalError(closure: @escaping FatalErrorClosureType) {
        fatalErrorClosure = closure
    }
    
    /// Restore the `fatalError` global function back to the original Swift implementation
    static func restoreFatalError() {
        fatalErrorClosure = defaultFatalErrorClosure
    }
}

import XCTest
@testable import TargetName

extension XCTestCase {
    func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void) {

        // arrange
        let expectation = self.expectation(description: "expectingFatalError")
        var assertionMessage: String? = nil

        // override fatalError. This will terminate thread when fatalError is called.
        FatalErrorUtil.replaceFatalError { message, _, _ in
            DispatchQueue.main.async {
                assertionMessage = message
                expectation.fulfill()
            }
            // Terminate the current thread after expectation fulfill
            Thread.exit()
            // Since current thread was terminated this code never be executed
            fatalError("It will never be executed")
        }

        // act, perform on separate thread to be able terminate this thread after expectation fulfill
        Thread(block: testcase).start()
        
        waitForExpectations(timeout: 0.1) { _ in
            // assert
            XCTAssertEqual(assertionMessage, expectedMessage)

            // clean up
            FatalErrorUtil.restoreFatalError()
        }
    }
}

class TestCase: XCTestCase {
    func testExpectPreconditionFailure() {
        expectFatalError(expectedMessage: "boom!") {
            doSomethingThatCallsFatalError()
        }
    }
}

【讨论】:

    【解决方案2】:

    Nimble(“适用于 Swift 和 Objective-C 的匹配器框架”)支持您:

    Swift 断言

    如果你使用的是 Swift,你可以使用 throwAssertion 匹配器来检查一个断言是否被抛出(例如 fatalError())。 @mattgallagher 的 CwlPreconditionTesting 库使这成为可能。

    // Swift
    
    // Passes if 'somethingThatThrows()' throws an assertion, 
    // such as by calling 'fatalError()' or if a precondition fails:
    expect { try somethingThatThrows() }.to(throwAssertion())
    expect { () -> Void in fatalError() }.to(throwAssertion())
    expect { precondition(false) }.to(throwAssertion())
    
    // Passes if throwing an NSError is not equal to throwing an assertion:
    expect { throw NSError(domain: "test", code: 0, userInfo: nil) }.toNot(throwAssertion())
    
    // Passes if the code after the precondition check is not run:
    var reachedPoint1 = false
    var reachedPoint2 = false
    expect {
        reachedPoint1 = true
        precondition(false, "condition message")
        reachedPoint2 = true
    }.to(throwAssertion())
    
    expect(reachedPoint1) == true
    expect(reachedPoint2) == false
    

    注意事项:

    • 此功能仅在 Swift 中可用。
    • 仅支持 x86_64 二进制文件,这意味着您不能在 iOS 设备上运行此匹配器,只能在模拟器上运行。
    • 支持 tvOS 模拟器,但使用不同的机制,要求您关闭 tvOS 方案的测试配置的调试可执行方案设置。

    【讨论】:

      【解决方案3】:

      斯威夫特 4 和斯威夫特 3

      根据肯的回答。

      在您的应用目标中添加以下内容:

      import Foundation
      
      // overrides Swift global `fatalError`
      public func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never {
          FatalErrorUtil.fatalErrorClosure(message(), file, line)
          unreachable()
      }
      
      /// This is a `noreturn` function that pauses forever
      public func unreachable() -> Never {
          repeat {
              RunLoop.current.run()
          } while (true)
      }
      
      /// Utility functions that can replace and restore the `fatalError` global function.
      public struct FatalErrorUtil {
      
          // Called by the custom implementation of `fatalError`.
          static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure
      
          // backup of the original Swift `fatalError`
          private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) }
      
          /// Replace the `fatalError` global function with something else.
          public static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) {
              fatalErrorClosure = closure
          }
      
          /// Restore the `fatalError` global function back to the original Swift implementation
          public static func restoreFatalError() {
              fatalErrorClosure = defaultFatalErrorClosure
          }
      }
      

      在您的测试目标中添加以下内容:

      import Foundation
      import XCTest
      
      extension XCTestCase {
          func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void) {
      
              // arrange
              let expectation = self.expectation(description: "expectingFatalError")
              var assertionMessage: String? = nil
      
              // override fatalError. This will pause forever when fatalError is called.
              FatalErrorUtil.replaceFatalError { message, _, _ in
                  assertionMessage = message
                  expectation.fulfill()
                  unreachable()
              }
      
              // act, perform on separate thead because a call to fatalError pauses forever
              DispatchQueue.global(qos: .userInitiated).async(execute: testcase)
      
              waitForExpectations(timeout: 0.1) { _ in
                  // assert
                  XCTAssertEqual(assertionMessage, expectedMessage)
      
                  // clean up
                  FatalErrorUtil.restoreFatalError()
              }
          }
      }
      

      测试用例:

      class TestCase: XCTestCase {
          func testExpectPreconditionFailure() {
              expectFatalError(expectedMessage: "boom!") {
                  doSomethingThatCallsFatalError()
              }
          }
      }
      

      【讨论】:

      • 效果很好!只需要用expectFatalError(expectedMessage: "boom!")更新样本
      • 摆脱unreachable()周围的“永远不会被执行”警告的最优雅的方法是什么?
      • XCTestCase 的扩展使用FatalErrorUtil 结构;我必须将 @testable import MyFramework 添加到导入中(我正在测试框架目标)。
      • 谢谢!在主线程上使用它有什么想法吗?例如,我正在测试从 XIB 构建视图,并且还必须在主线程上调用此代码。
      • 这会在 GCD 中为每次调用 expectFatalError 留下一个丢弃的线程,并且这些线程可能会旋转,因为 RunLoop.current.run() 可以立即返回。我通过使用Thread 而不是DispatchQueue 解决了这个问题,并通过调用Thread.exit() 退出了replaceFatalError 中的线程。
      【解决方案4】:

      感谢 nschumKen Ko 提供此答案背后的想法。

      Here is a gist for how to do it.

      Here is an example project.

      此答案不仅适用于致命错误。它也适用于其他断言方法(assertassertionFailurepreconditionpreconditionFailurefatalError

      1。将ProgrammerAssertions.swift 拖放到您正在测试的应用程序或框架的目标。除了你的源代码。

      ProgrammerAssertions.swift

      import Foundation
      
      /// drop-in replacements
      
      public func assert(@autoclosure condition: () -> Bool, @autoclosure _ message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          Assertions.assertClosure(condition(), message(), file, line)
      }
      
      public func assertionFailure(@autoclosure message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          Assertions.assertionFailureClosure(message(), file, line)
      }
      
      public func precondition(@autoclosure condition: () -> Bool, @autoclosure _ message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          Assertions.preconditionClosure(condition(), message(), file, line)
      }
      
      @noreturn public func preconditionFailure(@autoclosure message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          Assertions.preconditionFailureClosure(message(), file, line)
          runForever()
      }
      
      @noreturn public func fatalError(@autoclosure message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          Assertions.fatalErrorClosure(message(), file, line)
          runForever()
      }
      
      /// Stores custom assertions closures, by default it points to Swift functions. But test target can override them.
      public class Assertions {
      
          public static var assertClosure              = swiftAssertClosure
          public static var assertionFailureClosure    = swiftAssertionFailureClosure
          public static var preconditionClosure        = swiftPreconditionClosure
          public static var preconditionFailureClosure = swiftPreconditionFailureClosure
          public static var fatalErrorClosure          = swiftFatalErrorClosure
      
          public static let swiftAssertClosure              = { Swift.assert($0, $1, file: $2, line: $3) }
          public static let swiftAssertionFailureClosure    = { Swift.assertionFailure($0, file: $1, line: $2) }
          public static let swiftPreconditionClosure        = { Swift.precondition($0, $1, file: $2, line: $3) }
          public static let swiftPreconditionFailureClosure = { Swift.preconditionFailure($0, file: $1, line: $2) }
          public static let swiftFatalErrorClosure          = { Swift.fatalError($0, file: $1, line: $2) }
      }
      
      /// This is a `noreturn` function that runs forever and doesn't return.
      /// Used by assertions with `@noreturn`.
      @noreturn private func runForever() {
          repeat {
              NSRunLoop.currentRunLoop().run()
          } while (true)
      }
      

      2。将XCTestCase+ProgrammerAssertions.swift 拖放到您的测试目标。除了您的测试用例。

      XCTestCase+ProgrammerAssertions.swift

      import Foundation
      import XCTest
      @testable import Assertions
      
      private let noReturnFailureWaitTime = 0.1
      
      public extension XCTestCase {
      
          /**
           Expects an `assert` to be called with a false condition.
           If `assert` not called or the assert's condition is true, the test case will fail.
      
           - parameter expectedMessage: The expected message to be asserted to the one passed to the `assert`. If nil, then ignored.
           - parameter file:            The file name that called the method.
           - parameter line:            The line number that called the method.
           - parameter testCase:        The test case to be executed that expected to fire the assertion method.
           */
          public func expectAssert(
              expectedMessage: String? = nil,
              file: StaticString = __FILE__,
              line: UInt = __LINE__,
              testCase: () -> Void
              ) {
      
                  expectAssertionReturnFunction("assert", file: file, line: line, function: { (caller) -> () in
      
                      Assertions.assertClosure = { condition, message, _, _ in
                          caller(condition, message)
                      }
      
                      }, expectedMessage: expectedMessage, testCase: testCase) { () -> () in
                          Assertions.assertClosure = Assertions.swiftAssertClosure
                  }
          }
      
          /**
           Expects an `assertionFailure` to be called.
           If `assertionFailure` not called, the test case will fail.
      
           - parameter expectedMessage: The expected message to be asserted to the one passed to the `assertionFailure`. If nil, then ignored.
           - parameter file:            The file name that called the method.
           - parameter line:            The line number that called the method.
           - parameter testCase:        The test case to be executed that expected to fire the assertion method.
           */
          public func expectAssertionFailure(
              expectedMessage: String? = nil,
              file: StaticString = __FILE__,
              line: UInt = __LINE__,
              testCase: () -> Void
              ) {
      
                  expectAssertionReturnFunction("assertionFailure", file: file, line: line, function: { (caller) -> () in
      
                      Assertions.assertionFailureClosure = { message, _, _ in
                          caller(false, message)
                      }
      
                      }, expectedMessage: expectedMessage, testCase: testCase) { () -> () in
                          Assertions.assertionFailureClosure = Assertions.swiftAssertionFailureClosure
                  }
          }
      
          /**
           Expects an `precondition` to be called with a false condition.
           If `precondition` not called or the precondition's condition is true, the test case will fail.
      
           - parameter expectedMessage: The expected message to be asserted to the one passed to the `precondition`. If nil, then ignored.
           - parameter file:            The file name that called the method.
           - parameter line:            The line number that called the method.
           - parameter testCase:        The test case to be executed that expected to fire the assertion method.
           */
          public func expectPrecondition(
              expectedMessage: String? = nil,
              file: StaticString = __FILE__,
              line: UInt = __LINE__,
              testCase: () -> Void
              ) {
      
                  expectAssertionReturnFunction("precondition", file: file, line: line, function: { (caller) -> () in
      
                      Assertions.preconditionClosure = { condition, message, _, _ in
                          caller(condition, message)
                      }
      
                      }, expectedMessage: expectedMessage, testCase: testCase) { () -> () in
                          Assertions.preconditionClosure = Assertions.swiftPreconditionClosure
                  }
          }
      
          /**
           Expects an `preconditionFailure` to be called.
           If `preconditionFailure` not called, the test case will fail.
      
           - parameter expectedMessage: The expected message to be asserted to the one passed to the `preconditionFailure`. If nil, then ignored.
           - parameter file:            The file name that called the method.
           - parameter line:            The line number that called the method.
           - parameter testCase:        The test case to be executed that expected to fire the assertion method.
           */
          public func expectPreconditionFailure(
              expectedMessage: String? = nil,
              file: StaticString = __FILE__,
              line: UInt = __LINE__,
              testCase: () -> Void
              ) {
      
                  expectAssertionNoReturnFunction("preconditionFailure", file: file, line: line, function: { (caller) -> () in
      
                      Assertions.preconditionFailureClosure = { message, _, _ in
                          caller(message)
                      }
      
                      }, expectedMessage: expectedMessage, testCase: testCase) { () -> () in
                          Assertions.preconditionFailureClosure = Assertions.swiftPreconditionFailureClosure
                  }
          }
      
          /**
           Expects an `fatalError` to be called.
           If `fatalError` not called, the test case will fail.
      
           - parameter expectedMessage: The expected message to be asserted to the one passed to the `fatalError`. If nil, then ignored.
           - parameter file:            The file name that called the method.
           - parameter line:            The line number that called the method.
           - parameter testCase:        The test case to be executed that expected to fire the assertion method.
           */
          public func expectFatalError(
              expectedMessage: String? = nil,
              file: StaticString = __FILE__,
              line: UInt = __LINE__,
              testCase: () -> Void) {
      
                  expectAssertionNoReturnFunction("fatalError", file: file, line: line, function: { (caller) -> () in
      
                      Assertions.fatalErrorClosure = { message, _, _ in
                          caller(message)
                      }
      
                      }, expectedMessage: expectedMessage, testCase: testCase) { () -> () in
                          Assertions.fatalErrorClosure = Assertions.swiftFatalErrorClosure
                  }
          }
      
          // MARK:- Private Methods
      
          private func expectAssertionReturnFunction(
              functionName: String,
              file: StaticString,
              line: UInt,
              function: (caller: (Bool, String) -> Void) -> Void,
              expectedMessage: String? = nil,
              testCase: () -> Void,
              cleanUp: () -> ()
              ) {
      
                  let expectation = expectationWithDescription(functionName + "-Expectation")
                  var assertion: (condition: Bool, message: String)? = nil
      
                  function { (condition, message) -> Void in
                      assertion = (condition, message)
                      expectation.fulfill()
                  }
      
                  // perform on the same thread since it will return
                  testCase()
      
                  waitForExpectationsWithTimeout(0) { _ in
      
                      defer {
                          // clean up
                          cleanUp()
                      }
      
                      guard let assertion = assertion else {
                          XCTFail(functionName + " is expected to be called.", file: file.stringValue, line: line)
                          return
                      }
      
                      XCTAssertFalse(assertion.condition, functionName + " condition expected to be false", file: file.stringValue, line: line)
      
                      if let expectedMessage = expectedMessage {
                          // assert only if not nil
                          XCTAssertEqual(assertion.message, expectedMessage, functionName + " called with incorrect message.", file: file.stringValue, line: line)
                      }
                  }
          }
      
          private func expectAssertionNoReturnFunction(
              functionName: String,
              file: StaticString,
              line: UInt,
              function: (caller: (String) -> Void) -> Void,
              expectedMessage: String? = nil,
              testCase: () -> Void,
              cleanUp: () -> ()
              ) {
      
                  let expectation = expectationWithDescription(functionName + "-Expectation")
                  var assertionMessage: String? = nil
      
                  function { (message) -> Void in
                      assertionMessage = message
                      expectation.fulfill()
                  }
      
                  // act, perform on separate thead because a call to function runs forever
                  dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), testCase)
      
                  waitForExpectationsWithTimeout(noReturnFailureWaitTime) { _ in
      
                      defer {
                          // clean up
                          cleanUp()
                      }
      
                      guard let assertionMessage = assertionMessage else {
                          XCTFail(functionName + " is expected to be called.", file: file.stringValue, line: line)
                          return
                      }
      
                      if let expectedMessage = expectedMessage {
                          // assert only if not nil
                          XCTAssertEqual(assertionMessage, expectedMessage, functionName + " called with incorrect message.", file: file.stringValue, line: line)
                      }
                  }
          }
      }
      

      3。像往常一样正常使用assertassertionFailurepreconditionpreconditionFailurefatalError

      例如:如果您有一个执行如下除法的函数:

      func divideFatalError(x: Float, by y: Float) -> Float {
      
          guard y != 0 else {
              fatalError("Zero division")
          }
      
          return x / y
      }
      

      4。使用新方法 expectAssertexpectAssertionFailureexpectPreconditionexpectPreconditionFailureexpectFatalError 对它们进行单元测试。

      您可以使用以下代码测试0除法。

      func testFatalCorrectMessage() {
          expectFatalError("Zero division") {
              divideFatalError(1, by: 0)
          }
      }
      

      或者,如果您不想测试该消息,您只需这样做。

      func testFatalErrorNoMessage() {
          expectFatalError() {
              divideFatalError(1, by: 0)
          }
      }
      

      【讨论】:

      • 我不明白为什么我必须增加 noReturnFailureWaitTime 值才能继续进行单元测试。但它有效。谢谢
      • 1不是太局限了吗?它迫使您将一个目标仅用于单元测试,而将另一个目标用于实际分发,即测试人员。否则,如果测试人员点击fatalError,应用程序将挂起但不会失败。或者,需要在运行单元测试之前将带有自定义断言的代码直接注入到应用程序/框架目标中,这在本地或 CI 服务器上运行时不太实用。
      • 我试图使这段代码可重用,以便能够将其作为 cocoapod 插入,但是将覆盖函数作为主要应用程序/框架目标的一部分的要求非常有限,尤其是当我必须扩展到 10 多个框架。不确定最终结果是否能证明我的权衡是合理的。
      • 你说得对。目前,提供的解决方案是 hack,我不鼓励您在生产中使用。
      【解决方案5】:

      这个想法是用你自己的替换内置的fatalError函数,它在单元测试的执行过程中被替换,这样你就可以在其中运行单元测试断言。

      然而,棘手的部分是fatalError@noreturn,所以你需要用一个永远不会返回的函数来覆盖它。

      覆盖致命错误

      仅在您的应用目标中(不要添加到单元测试目标中):

      // overrides Swift global `fatalError`
      @noreturn func fatalError(@autoclosure message: () -> String = "", file: StaticString = __FILE__, line: UInt = __LINE__) {
          FatalErrorUtil.fatalErrorClosure(message(), file, line)
          unreachable()
      }
      
      /// This is a `noreturn` function that pauses forever
      @noreturn func unreachable() {
          repeat {
              NSRunLoop.currentRunLoop().run()
          } while (true)
      }
      
      /// Utility functions that can replace and restore the `fatalError` global function.
      struct FatalErrorUtil {
      
          // Called by the custom implementation of `fatalError`.
          static var fatalErrorClosure: (String, StaticString, UInt) -> () = defaultFatalErrorClosure
      
          // backup of the original Swift `fatalError`
          private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) }
      
          /// Replace the `fatalError` global function with something else.
          static func replaceFatalError(closure: (String, StaticString, UInt) -> ()) {
              fatalErrorClosure = closure
          }
      
          /// Restore the `fatalError` global function back to the original Swift implementation
          static func restoreFatalError() {
              fatalErrorClosure = defaultFatalErrorClosure
          }
      }
      

      扩展

      将以下扩展添加到您的单元测试目标中:

      extension XCTestCase {
          func expectFatalError(expectedMessage: String, testcase: () -> Void) {
      
              // arrange
              let expectation = expectationWithDescription("expectingFatalError")
              var assertionMessage: String? = nil
      
              // override fatalError. This will pause forever when fatalError is called.
              FatalErrorUtil.replaceFatalError { message, _, _ in
                  assertionMessage = message
                  expectation.fulfill()
              }
      
              // act, perform on separate thead because a call to fatalError pauses forever
              dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), testcase)
      
              waitForExpectationsWithTimeout(0.1) { _ in
                  // assert
                  XCTAssertEqual(assertionMessage, expectedMessage)
      
                  // clean up 
                  FatalErrorUtil.restoreFatalError()
              }
          }
      }
      

      测试用例

      class TestCase: XCTestCase {
          func testExpectPreconditionFailure() {
              expectFatalError("boom!") {
                  doSomethingThatCallsFatalError()
              }
          }
      }
      

      我从这篇文章中得到了关于单元测试assertprecondition 的想法: Testing assertion in Swift

      【讨论】:

      • 这看起来很有希望。今天晚些时候我会试一试,并将其标记为已回答。
      • 进行了编辑以修复几个编译问题,并且还进行了重构以封装在一个 util 结构中以减少全局状态
      • 我不清楚是否/如何更新 Swift 3 从 @noreturn-> Never 的移动。也许我只是遗漏了一些东西——你如何结束 unreachable 函数的执行?
      • @GuyDaher 基本想法是在XTCFailhandler 块中使用waitForExpectationsWithTimeout,并希望您的Never 在这段时间内被调用。类似doSomething() waitForExpectations(timeout: ASYNC_TIMEOUT, handler: {error in if let error = error { XCTFail(error.localizedDescription) }
      • @GuyDaher 我还将我的Never 函数移到了委托协议中,这样我就可以将我的测试类设置为用于测试目的的委托,它会满足预期。
      猜你喜欢
      • 2011-06-12
      • 2018-10-02
      • 1970-01-01
      • 2014-08-06
      • 1970-01-01
      • 1970-01-01
      • 2012-11-21
      • 2015-02-05
      相关资源
      最近更新 更多