如何在Swift中为fatalError
代码路径实现单元测试?
例如,我有以下Swift代码:
func divide(x: Float, by y: Float) -> Float {
guard y != 0 else {
fatalError("Zero division")
}
return x / y
}
我想对 y = 0 的情况进行单元测试。
请注意,我想使用 fatalError 而不是其他任何断言函数。
如何在Swift中为fatalError
代码路径实现单元测试?
例如,我有以下Swift代码:
func divide(x: Float, by y: Float) -> Float {
guard y != 0 else {
fatalError("Zero division")
}
return x / y
}
我想对 y = 0 的情况进行单元测试。
请注意,我想使用 fatalError 而不是其他任何断言函数。
这个想法是用你自己的函数替换内置的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()
}
}
}
我从这篇有关单元测试assert
和precondition
的文章中得到了灵感:Testing assertion in Swift
@noreturn
到-> Never
的变化。也许我只是漏掉了什么 - 你如何结束unreachable
函数的执行? - RichardwaitForExpectationsWithTimeout
,并在其 handler
块中使用 XCTFail
,希望您的 Never
在该时间段内被调用。类似于
doSomething() waitForExpectations(timeout: ASYNC_TIMEOUT, handler: {error in if let error = error { XCTFail(error.localizedDescription) }
- RichardNever
函数移动到委托协议中,以便我可以将我的测试类设置为测试目的的委托,并且它将满足期望。 - Richard基于Ken的回答。
在你的应用目标中添加以下内容:
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!")
更新示例即可。 - truskunreachable()
周围的“永远不会被执行”的警告? - Nicolas MiariXCTestCase
的扩展使用了 FatalErrorUtil
结构体;我不得不添加 @testable import MyFramework
到导入中(我正在测试一个框架目标)。 - Nicolas MiariexpectFatalError
都会在 GCD 中留下一个被丢弃的线程,而这些线程可能会旋转,因为 RunLoop.current.run()
可能会立即返回。我通过使用 Thread
而不是 DispatchQueue
来解决了这个问题,并通过调用 Thread.exit()
在 replaceFatalError
中退出了线程。 - jedwidz感谢 nschum 和 Ken Ko 提供了这个答案的想法。
这个答案不仅适用于致命错误,还适用于其他断言方法(assert
、assertionFailure
、precondition
、preconditionFailure
和fatalError
)。
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)
}
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)
}
}
}
}
assert
、assertionFailure
、precondition
、preconditionFailure
和fatalError
,像往常一样使用。例如:如果您有一个执行除法的函数如下:
func divideFatalError(x: Float, by y: Float) -> Float {
guard y != 0 else {
fatalError("Zero division")
}
return x / y
}
expectAssert
、expectAssertionFailure
、expectPrecondition
、expectPreconditionFailure
和expectFatalError
进行单元测试。您可以使用以下代码测试0除法。
func testFatalCorrectMessage() {
expectFatalError("Zero division") {
divideFatalError(1, by: 0)
}
}
如果您不想测试消息,只需执行以下操作。
func testFatalErrorNoMessage() {
expectFatalError() {
divideFatalError(1, by: 0)
}
}
1
太过限制了吗?它迫使您只为单元测试设置一个目标,而为另一个目标即分发给测试人员的应用保留。否则,如果测试人员遇到fatalError
,应用程序将挂起但不会失败。或者,需要将带有自定义断言的代码直接注入到应用/框架目标中,然后才能运行单元测试,这在本地运行或CI服务器上运行时实际上并不是很实用。 - i4niacNimble(“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方案的测试配置的Debug可执行方案设置。
SWIFT 5, 4
这个版本在每次调用 expectFatalError 时不会留下一个被丢弃的线程。这是通过使用 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()
}
}
}
internal var triggerFatalError = Swift.fatalError
替换了几乎所有的应用程序代码,并直接调用它。 - Michael Long