DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Please enter at least three characters to search
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Last call! Secure your stack and shape the future! Help dev teams across the globe navigate their software supply chain security challenges.

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workloads.

Releasing software shouldn't be stressful or risky. Learn how to leverage progressive delivery techniques to ensure safer deployments.

Avoid machine learning mistakes and boost model performance! Discover key ML patterns, anti-patterns, data strategies, and more.

Related

  • Testing the Untestable and Other Anti-Patterns
  • Refactor Switch to a One-Liner
  • The Anatomy of a Microservice, Satisfying the Interface
  • The Anatomy of Good Unit Testing

Trending

  • Teradata Performance and Skew Prevention Tips
  • Microsoft Azure Synapse Analytics: Scaling Hurdles and Limitations
  • Understanding Java Signals
  • The Role of Retrieval Augmented Generation (RAG) in Development of AI-Infused Enterprise Applications
  1. DZone
  2. Coding
  3. Frameworks
  4. How to Test fatalError in Swift

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.

By 
Marco Santarossa user avatar
Marco Santarossa
·
Sep. 19, 17 · Tutorial
Likes (2)
Comment
Save
Tweet
Share
7.3K Views

Join the DZone community and get the full member experience.

Join For Free

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

  • https://stackoverflow.com/a/44140448/5109911
unit test Swift (programming language) Implementation

Published at DZone with permission of Marco Santarossa, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Testing the Untestable and Other Anti-Patterns
  • Refactor Switch to a One-Liner
  • The Anatomy of a Microservice, Satisfying the Interface
  • The Anatomy of Good Unit Testing

Partner Resources

×

Comments
Oops! Something Went Wrong

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • support@dzone.com

Let's be friends:

Likes
There are no likes...yet! 👀
Be the first to like this post!
It looks like you're not logged in.
Sign in to see who liked this post!