Skip to content

jonreid/FailKit

Repository files navigation

FailKit

Build

Writing custom test assertions makes your tests more expressive and easier to maintain.

But how do you support both XCTest and Swift Testing?

XCTest uses XCTFail. Swift Testing uses Issue.record. You can’t just call one from the other. You could write your assertions twice — or use FailKit.

Contents

Features

  • Unified Failure Reporting:
    Works with XCTest and Swift Testing, including source location.

  • Cleaner Value Descriptions:
    Optional values without Optional(…); strings quoted and escaped.

  • Assertion Testing:
    Use FailSpy to test your custom assertions: did they fail, and how?

Usage

Fail.fail

Let’s say we want a custom equality assertion that’s clearer than XCTestAssertEqual:

func assertEqual<T: Equatable>(
    _ actual: T,
    expected: T,
    file: StaticString = #filePath,
    line: UInt = #line
) {
    if actual == expected { return }
    XCTFail("Expected \(expected), but was \(actual)", file: file, line: line)
}

This works — until you start migrating to Swift Testing. You’ll need to duplicate the function, rename it, and re-implement the failure logic.

With FailKit, you can write one assertion that works in both worlds:

func assertEqual<T: Equatable>(
    _ actual: T,
    expected: T,
    fileID: String = #fileID,
    filePath: StaticString = #filePath,
    line: UInt = #line,
    column: UInt = #column
) {
    if actual == expected { return }
    Fail.fail(
        message: "Expected \(expected), but was \(actual)",
        location: SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)
    )
}

Fail.fail automatically routes to the appropriate testing framework.

Better Value Descriptions with describe

Consider this failure message:

"Expected \(expected), but was \(actual)"

Depending on the type, the results may be unclear:

Type Without FailKit With describe()
Int Expected 123, but was 456 Expected 123, but was 456
Int? Expected Optional(123), but was Optional(456) Expected 123, but was 456
String Expected ab cd, but was de fg Expected "ab cd", but was "de fg"

Improve this by using:

"Expected \(describe(expected)), but was (describe(actual))"

Optional values are unwrapped. Strings are quoted and escaped, making special characters visible.

Add a Distinguishing Message

When a test has multiple assertions, it helps to add a short distinguishing message:

let result = 6 * 9
assertEqual(result, 42, "answer to the ultimate question")

To support this, add a message parameter with a default:

When a test has multiple assertions, it’s often helpful to add a distinguishing message. This helps us identify the point of failure even from raw console output, as you get from a build server.

To separate this distinguishing message from the main message, use FailKit’s messageSuffix function. First, add a String parameter with a default value of empty string:

func assertEqual<T: Equatable>(
    _ actual: T,
    expected: T,
    message: String = "",
    ...
)

And append it using messageSuffix:

"Expected \(expected), but was \(actual)" + messageSuffix(message)

FailKit will insert a separator if the message is non-empty:

Expected 42, but was 54 - answer to the ultimate question

Test Your Custom Assertions

You can test your assertion helpers using FailSpy. First, modify your function to take a Failing parameter:

func assertEqual<T: Equatable>(
    _ actual: T,
    expected: T,
    ...,
    failure: any Failing = Fail()
)

Then, call failure.fail(…) instead of Fail.fail(…).

To test it:

✅ Success Case (No Failure)

@Test
func equal() async throws {
    let failSpy = FailSpy()
    
    assertEqual(1, expected: 1, failure: failSpy)

    #expect(failSpy.callCount == 0)
}

❌ Failure Case (Should Fail)

@Test
func mismatch() async throws {
    let failSpy = FailSpy()

    assertEqual(2, expected: 1, failure: failSpy)

    #expect(failSpy.callCount == 1)
    #expect(failSpy.messages.first == "Expected 1, but was 2")
}

You can now test your own test helpers — and TDD them, too.

describe Details

The describe() function formats values to improve test output:

  • Optionals: Removes Optional(…) wrapper
  • Strings: Wraps in quotes and escapes special characters
    • \" (quote)
    • \n, \r, \t (newline, carriage return, tab)
  • Other types: Use default Swift description

💡 See a Working Example

Check out the Demo folder to see:

  • A real custom assertion built using FailKit
  • How to test that assertion using FailSpy

It’s a complete, working example you can use as a starting point for your own helpers.

Installation

Use Swift Package Manager:

dependencies: [
    .package(url: "https://github.com/jonreid/FailKit.git", from: "1.0.0"),
],

snippet source | anchor

And in your target:

dependencies: ["FailKit"]

snippet source | anchor

About the Author

Jon Reid is the author of iOS Unit Testing by Example.
Find more at Quality Coding.

Bluesky Mastodon YouTube

About

Write expressive custom assertions that work on either Swift Testing or XCTest

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •