Quality Coding
Shares
Emoji: Clamp

How to Make Custom Test Assertions in Swift

Shares

When a test fails, we want to know the location of the failure. Getting this information in Objective-C required us to dance with the preprocessor. But with Swift, it’s much more straightforward.

By “location of the failure,” I mean the file name and line number of the code that calls the assertion.

#file and #line

Swift directly incorporates these important values into the language as “literal expressions”. They are #file and #line. You can use them anywhere, but they’re particularly useful as default parameter values.

Do you know the assertion XCTFail? That’s the one that will always report a failure. Let’s look at its signature:

public func XCTFail(_ message: String = "",
                    file: StaticString = #file,
                    line: UInt = #line)

There are 3 parameters: a message, a file name, and a line number. We usually call this the message alone, not specifying the last two parameters. Their default values are #file and #line, which evaluate to their position at the point of call. This is important. We don’t get the location of the function. Instead, we get the location of the place that calls the function.

Motivating example: Testing tuples

Let’s say you want a test that confirms the values of a tuple. We’ll use a pair of integers for simplicity. How would you write a test to check its values? I’d first try this:

XCTAssertEqual(pair, (3, 11))

But this doesn’t work because tuples aren’t Equatable. OK, then how about this?

XCTAssert(pair == (3, 11))

This one does work because there are generic operators for pairs and other tuples. But consider what it reports upon failure:

XCTAssertTrue failed –

All it tells us is that the assertion failed. It doesn’t tell us why. Every time you have to fire up the debugger to analyze a test failure, time is wasted. Why can’t we get this information the first time around?

The XCTest assertions all have an optional message parameter. So we can use this to report the actual value:

let pair = (1, 2)
XCTAssert(pair == (3, 11), "was \(pair)")

This gives us the following message:

XCTAssertTrue failed – was (1, 2)

That’s better! But having to repeat this code for every pair we test… that’s duplicate code, crying out for refactoring. Time to apply “Extract Method”…

Writing a custom test assertion

Let’s use what we learned about #file and #line. We can start writing a function to check pairs of integers:

func assertIntPairsEqual(
        actual: (_: Int, _: Int),
        expected: (_: Int, _: Int),
        file: StaticString = #file, line: UInt = #line)

Now we know that within this function, the file and line parameters will reflect the location of the point of call.

Writing the comparison is easy. What do we do if it fails? We want to report it to XCTest.

Here’s the trick: Remember XCTFail? It has those optional parameters for file and line. What if we don’t use their default values, but specify our own? And the optional message while we’re at it:

func assertIntPairsEqual(
        actual: (_: Int, _: Int),
        expected: (_: Int, _: Int),
        file: StaticString = #file, line: UInt = #line) {
    if actual != expected {
        XCTFail("Expected \(expected) but was \(actual)",
                file: file, line: line)
    }
}
let pair = (1, 2)
assertIntPairsEqual(actual: pair, expected: (3, 11))

This produces the following message:

failed – Expected (3, 11) but was (1, 2)

There we go! To top it off, we can make it more generic. The following assertion can handle any pairs of any Equatable types T and U:

func assertPairsEqual<T: Equatable, U: Equatable>(
        actual: (_: T, _: U),
        expected: (_: T, _: U),
        file: StaticString = #file, line: UInt = #line) {

Conclusion

Here are the steps for creating specialized test assertions in Swift:

  • Define your assertion as a helper function.
  • Design the parameters to be unambiguous.
  • Include optional parameters for file and line.
  • Upon failure, call XCTFail, passing the file and line arguments.
  • Report all the information you need to diagnose failures.
  • Can you make the assertion generic?

The Swift implementation of XCTest makes this easy.

Have you written any custom test assertions in Swift? Please describe them below!

About the Author Jon Reid

Programming was fun when I was a kid. But working in Silicon Valley, I saw poor code lead to fear, with real human costs. Looking for ways to make my life better, I learned about Design Patterns, Refactoring, and Test-Driven Development (TDD). Programming became fun again! I've now been doing TDD in Apple environments for 18 years. I'm committed to software crafting as a discipline, hoping we can all reach greater effectiveness and joy.

follow me on:
  • I like this technique and would like to add that with Swift you can even define a func inside another func. For example when you want to do multiple similar assertions during one test.

    Example:

    func testReset() {
      func assertInDefaultState(foo: Foo, line: UInt = #line) {
        XCTAssertTrue(foo.bar, line: line)
        XCTAssertFalse(foo.foo, line: line)
        // ...
      }
    
      let foo = Foo()
      assertInDefaultState(foo)
      foo.bar = false
      // more modifications and possibly assertions
      foo.reset()
      assertInDefaultState(foo)
    }
    

    One more example I’d like to share is how I assert that two NSIndexPaths are the same.

    func assertEqualIndexPaths(first: NSIndexPath, _ second: NSIndexPath, line: UInt = #line) {
      let a = (section: first.section, row: first.row)
      let b = (section: second.section, row: second.row)
    
      XCTAssertTrue(a == b, "\(a) != \(b)", line: line)
    }
    

    I use it in one place in one file, so I don’t use #file here. It’s actually also just a function defined inside test function.

    Hope there ideas will help someone :)

  • Dan Cutting says:

    Hi Jon, great post! I had been wondering if there were a simple way to report the right line numbers in my helper verification functions, and this is a great solution.

  • Rudi Farkas says:

    Better late than never, I just found your excellent article
    and used your ideas (still valid in Swift 5.2) to write assertMatches below. Thanks!

    // example 1 - pass
            assertMatches("(Date())", #"^d{4}-d{2}-d{2} d{2}:d{2}:d{2} +d{4}$"#)
    
    // example 2 - fail ...
            assertMatches("(Date())", #"^d{4}-d{2}-d{2} d{2}:d{2}:d{2} +d{4}===$"#)
    
    // ... with message
    failed - match '2020-03-20 21:17:57 +0000' against pattern '^d{4}-d{2}-d{2} d{2}:d{2}:d{2} +d{4}===$'
    
    
    // code
    
    func assertMatches(
        _ actual: String,
        _ pattern: String,
        file: StaticString = #file, line: UInt = #line
    ) {
        if !(actual ~= pattern) {
            XCTFail("match '(actual)' against pattern '(pattern)'",
                    file: file, line: line)
        }
    }
    
    /**
     Originally from
     https://www.hackingwithswift.com/articles/108/how-to-use-regular-expressions-in-swift
     */
    
    extension NSRegularExpression {
        convenience init(_ pattern: String) {
            do {
                try self.init(pattern: pattern)
            } catch {
                preconditionFailure("Illegal regular expression: (pattern).")
            }
        }
    
        func matches(_ string: String) -> Bool {
            let range = NSRange(location: 0, length: string.utf16.count)
            return firstMatch(in: string, options: [], range: range) != nil
        }
    }
    
    func ~= (lhs: String, rhs: String) -> Bool {
        guard let regex = try? NSRegularExpression(pattern: rhs) else { return false }
        let range = NSRange(location: 0, length: lhs.utf16.count)
        return regex.firstMatch(in: lhs, options: [], range: range) != nil
    }
    

  • >