How to Test fatalError in Swift
In iOS development, Swift's fatalError is one way to manage failures. Learn how to test fatalError without crashing your tests.
Join the DZone community and get the full member experience.
Join For FreeIntroduction
Swift has several ways to manage failures. One of them is the function fatalError
. Since we should cover our production code with tests as much as possible, we need also a way to test the fatalError
. By default, there are no ways to handle it. If we test a function with a fatalError
, our tests would crash.
In this article, we are going to see a safe way to test if a fatalError
is called in our production code.
Happy reading!
Replace Default fatalError
By default, we cannot test if a method calls a fatalError
since we don’t have ways to wrap the failure and the tests would crash. For this reason, we need a way to handle the fatalError
function to prevent any crashes in our tests. To achieve it, we should write a custom fatalError
function.
If we have a look at the Swift interface for the function fatalError
, we would find this:
/// Unconditionally prints a given message and stops execution.
///
/// - Parameters:
/// - message: The string to print. The default is an empty string.
/// - file: The file name to print with `message`. The default is the file
/// where `fatalError(_:file:line:)` is called.
/// - line: The line number to print along with `message`. The default is the
/// line number where `fatalError(_:file:line:)` is called.
public func fatalError(_ message: @autoclosure () -> String = default, file: StaticString = #file, line: UInt = #line) -> Never
Our goal is to replace this function with a custom one. Therefore, we must declare a new function with the same signature in our application. The important thing is declaring it at the top-level. It means that it mustn’t be inside any classes/structs/enums.
We can start creating a new file called FatalErrorUtil.swift
. Then, at the top-level of this file, we can add our new fatalError
method:
//
// FatalErrorUtil.swift
//
// Copyright © 2017 MarcoSantaDev. All rights reserved.
//
import Foundation
func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never {
}
With this new method, every time we use fatalError
in our code, the compiler will use this function instead of the default one of Swift.
At this point, we need to add an implementation. The strategy for this implementation is using the default Swift fatalError
implementation for the production code and a custom one for unit tests.
We can use the struct FatalErrorUtil
which will provide the implementation of fatalError
:
struct FatalErrorUtil {
// 1
static var fatalErrorClosure: (String, StaticString, UInt) -> Never = defaultFatalErrorClosure
// 2
private static let defaultFatalErrorClosure = { Swift.fatalError($0, file: $1, line: $2) }
// 3
static func replaceFatalError(closure: @escaping (String, StaticString, UInt) -> Never) {
fatalErrorClosure = closure
}
// 4
static func restoreFatalError() {
fatalErrorClosure = defaultFatalErrorClosure
}
}
- Closure which provides the implementation of
fatalError
. By default, it uses the one provided by Swift. - Default
fatalError
implementation provided by Swift. - Static method to replace the
fatalError
implementation with a custom one. We’ll see later how to use it for unit tests. - Restores the
fatalError
implementation with the default one. We’ll need it later for the unit tests.
The next step is using this struct in our custom fatalError
function:
func fatalError(_ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) -> Never {
FatalErrorUtil.fatalErrorClosure(message(), file, line)
}
So far so good. If we use fatalError
in our production code, we can expect the same behavior as the one provided by Swift.
Unit Test Handling
In the previous section, we introduced a new fatalError
implementation. Now, we are able to handle it for the unit tests.
We can start adding a new file XCTestCase+FatalError.swift
in the project target of our unit tests to extend XCTestCase
.
The target membership of the file should be something similar to this:
In this XCTestCase
extension, we can add a method expectFatalError
which will wrap the fatalError
failure:
func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void)
With this method, we can wrap fatalError
and test if the method under test calls a fatalError
function with an expected message. We’ll see later how to use it.
Inside this method, we must replace the default fatalError
implementation with a mock to test if it’s called with an expected message:
let expectation = self.expectation(description: "expectingFatalError")
var assertionMessage: String? = nil
FatalErrorUtil.replaceFatalError { message, _, _ in
assertionMessage = message
expectation.fulfill()
self.unreachable()
}
If the compiler doesn’t find FatalErrorUtil.replaceFatalError
, it means that you must add @testable import <ProjectName>
at the beginning of the file.
We need expectation
because it’s an asynchronous test.
As we may have noticed in the example above, we call a method unreachable
which is this one:
private func unreachable() -> Never {
repeat {
RunLoop.current.run()
} while (true)
}
fatalError
is a function which returns Never
. It means that this function will never finish its execution since will be stopped by either a failure or a thrown error. In this case, a normal fatalError
would never complete since it crashes the application. Therefore, we must simulate a never returning function. We can do it with an infinite loop—like in method unreachable
. If we use the approach of an infinite loop, we should call RunLoop.current.run()
to let execute any services attached to the main thread. You can read more details about this method here.
Then, we must execute the testcase
closure in a background thread, since the main one will be blocked by the infinite loop:
DispatchQueue.global(qos: .userInitiated).async(execute: testcase)
Finally, we must handle the expectation
completion to test the fatalError
message and restore the default fatalError
implementation:
waitForExpectations(timeout: 2) { _ in
XCTAssertEqual(assertionMessage, expectedMessage)
FatalErrorUtil.restoreFatalError()
}
We’ve just finished the implementation of our XCTestCase
extension, and it should be like this:
import XCTest
extension XCTestCase {
func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void) {
let expectation = self.expectation(description: "expectingFatalError")
var assertionMessage: String? = nil
FatalErrorUtil.replaceFatalError { message, _, _ in
assertionMessage = message
expectation.fulfill()
self.unreachable()
}
DispatchQueue.global(qos: .userInitiated).async(execute: testcase)
waitForExpectations(timeout: 2) { _ in
XCTAssertEqual(assertionMessage, expectedMessage)
FatalErrorUtil.restoreFatalError()
}
}
private func unreachable() -> Never {
repeat {
RunLoop.current.run()
} while (true)
}
}
Test Example
We’ve completed everything to test our fatalError
calls.
Now, we can see a plain example to understand how to use the new method expectFatalError
.
Let’s consider the following Handler
class:
class Handler {
func handle(value: Int) {
guard value > 5 else {
fatalError("value is too small")
}
}
}
We call a fatalError
if the argument of handle
is less than 6.
We can test this method like this:
class HandlerTests: XCTestCase {
func test_Handler_ValueLessThanSix_CallsFatalError() {
let handler = Handler()
expectFatalError(expectedMessage: "value is too small") {
handler.handle(value: 3)
}
}
}
This test would fail if either the method handle
doesn’t call the fatalError
or if expectedMessage
is not the same message as the fatalError
.
Conclusion
In this article, we focused on the fatalError
but we can use a similar approach for other failure methods like assert
and preconditionFailure
. You can have a look here for more details.
Reference Links
Published at DZone with permission of Marco Santarossa, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments