Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

How to Test fatalError in Swift

DZone's Guide to

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.

· Mobile Zone ·
Free Resource

Introduction

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.

Image title

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
    }
}
  1. Closure which provides the implementation of fatalError. By default, it uses the one provided by Swift.
  2. Default fatalError implementation provided by Swift.
  3. Static method to replace the fatalError implementation with a custom one. We’ll see later how to use it for unit tests.
  4. 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 fatalErrorimplementation:

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

Topics:
mobile ,mobile testing ,swift ,mobile app development ,ios

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}