Swift-testing and Errors with associated types

Just wondering if there is a more succinct syntax than what I'm doing below when trying to test Errors when the Error type has associated types?

Specifically anything better I could be doing than the switch statements when all I want to know is if it is that one subtype?


extension TestingErrors.ExampleError:Equatable {}

@Suite("Test Errors")
struct TestingErrors {
    enum ExampleError:Error {
        case unknownError(_ message: String)
        case codedError(_ code:Int)
        case noExtras
    }
    
    func throwsMessage(_ message:String) throws {
        throw ExampleError.unknownError(message)
    }
    
    func throwsCode(_ codeToThrow:Int) throws {
        throw ExampleError.codedError(codeToThrow)
    }
    
    func throwsSimple() throws {
        throw ExampleError.noExtras
    }
    
    @Test func simpleErrorTest() async throws {
        //required explicit equatable
        #expect(throws: ExampleError.noExtras) {
            try  throwsSimple()
        }
    }
    
    @Test func testForExpectedCode() async throws {
        let expectedCode = 42
        
        //when you know the exact code, everything is easy.
        await #expect(throws: ExampleError.codedError(expectedCode)) {
            try await throwsCode(expectedCode)
        }
        
        //but if it is in a range? 
        await #expect {
            try await throwsCode(expectedCode)
        } throws: { error in
            guard let error = error as? ExampleError else {
                return false
            }
            switch error {
            case .codedError(let code):
                return (20...49).contains(code)
            default:
                return false
            }
        }
    }
    
    @Test func testForAnyCode() async throws {
         //Can't find the right way to frame this? Is it possible?
//        #expect(throws: ExampleError.codedError(_)) {
//            try await throwsCode(Int.random(in: 0...5))
//        }
        
        await #expect {
            try await throwsCode(Int.random(in: 0...5))
        } throws: { error in
            guard let error = error as? ExampleError else {
                return false
            }
            switch error {
            case .codedError(_):
                return true
            default:
                return false
            }
        }
    }
    
    @Test func testForMessage() async throws {
        let messageToThrow = "well that was a big oops"
        let messageShouldContain = "oops"
        //Would have been nice but a big ask..
//        await #expect(throws: ExampleError.unknownError(.contains(messageShouldContain))) {
//            try await throwsMessage(messageToThrow)
//        }
        
        await #expect {
            try await throwsMessage(messageToThrow)
        } throws: { error in
            guard let error = error as? ExampleError else {
                return false
            }
            switch error {
            case .unknownError(let message):
                return message.contains(messageShouldContain)
            default:
                return false
            }
        }
    }
}

If your error type conforms to Equatable, you can pass an instance of it to compare against exactly instead of passing just the error's type. For more complex comparisons, try something like:

let error = #expect(throws: MyError.self) {
  try foo()
}
if let error {
  #expect(error.property == 123)
  /* ... */
}

It is not syntactically valid to write #expect(case ...) because case statements are not expressions.

2 Likes

Thanks! That does let me tighten it up quite a bit.

    enum ExampleError:Error {
        case unknownError(_ message: String)
        case codedError(_ code:Int)
        case noExtras
        
         //would be a pain to add for a lot of them, but could be worse.
        func isCodedError() -> Bool {
            switch self {
            case .codedError(_):
                return true
            default:
                return false
            }
        }
    }

    @Test func testForExpectedCodeV2() async throws {
        let expectedCode = 42
        
        let error = await #expect(throws: ExampleError.self) {
            try await throwsCode(expectedCode)
        }
        #expect(error != nil)
        #expect(error == .codedError(expectedCode))
    }
    
    @Test func testForAnyCodeV2() async throws {        
        let error = await #expect(throws: ExampleError.self) {
            try await throwsCode(Int.random(in: 0...5))
        }
        #expect(error?.isCodedError() == true)
    }

You can simplify your code even further by using let error = try #require(throws: ...). It will throw a (different) error on failure, so its result is non-optional.

2 Likes

There are several missing features which make your code have to look terrible:

  1. is case
  2. An overload of #expect which takes a () -> Bool instead of a Bool.
  3. Swift Testing's lack of incorporation of typed throws.*
@Suite("Test Errors")
struct TestingErrors {
  enum ExampleError: Error & Equatable {
    case unknownError(_ message: String)
    case codedError(_ code:Int)
    case noExtras
  }

  func `throw`(message: String) throws(ExampleError) {
    throw .unknownError(message)
  }

  func `throw`(code: Int) throws(ExampleError) {
    throw .codedError(code)
  }

  func `throw`() throws(ExampleError) {
    throw .noExtras
  }

  @Test func simpleErrorTest() throws {
    #expect(throws: ExampleError.noExtras, performing: `throw`)
  }

  @Test func testForExpectedCode() throws {
    let expectedCode = 42

    #expect(throws: ExampleError.codedError(expectedCode)) {
      try `throw`(code: expectedCode)
    }

    #expect({
      guard case .codedError(let code) = (#expect(throws: ExampleError.self) {
        try `throw`(code: expectedCode)
      }) else { return false }
      return (20...49).contains(code)
    } ())
  }

  @Test func testForAnyCode() throws {
    #expect({
      guard case .codedError = (#expect(throws: ExampleError.self) {
        try `throw`(code: .random(in: 0...5))
      }) else { return false }
      return true
    }() )
  }

  @Test func testForMessage() throws {
    #expect({
      guard case .unknownError(let message) = (#expect(throws: ExampleError.self) {
        try `throw`(message: "well that was a big oops")
      }) else { return false }
      return message.contains("oops")
    } ())
  }
}

* This is the least important; typed throws feel like they're another abandoned feature at this point, and require redundant annotation which is worse than providing a metatype.

expect {
  guard case .unknownError(let message) = (expectThrows { () throws(ExampleError) in
    try `throw`(message: "well that was a big oops")
  }) else { return false }
  return message.contains("oops")
}
func expectThrows<Error>(performing expression: () throws(Error) -> some Any) -> Error? {
  #expect(throws: Error.self, performing: expression)
}

func expect(_ condition: () -> Bool) {
  #expect(condition())
}

TIL that the thrown, typed error is returned! So valuable, but I don't believe this is visible at all from the documentation?

It is documented. It's new in Swift 6.1.

1 Like

Looks like some of the See Also links on other pages are incorrect but yes, you are right — found it thank you :folded_hands:t2:.

I'm not sure what you're referring to. Can you point me at the bad links and we can make sure to get them corrected?

The see also section on this page: (entered via google search so probably old but its not clear)

Thanks! That section indeed appears to be out of date. @smontgomery or @suzannaratcliff would you please let our DevPub colleagues know?

I'm pretty new to swift-testing and it's pretty new so I'm not sure I follow everything you're saying but between your and grynspan's tips I'm ending up with something that doesn't feel that terrible to me!

Maybe I have low standards ;)

I even got a self contained function that I might move up to the Suite level out of it! Thanks!

Now to replace that random with parameterization!

        #expect(errorContainedIn(range:20...49, with:Int.random(in: 20...49), from: throwsCode))
        
        func errorContainedIn(range:ClosedRange<Int>, with:Int,
            from function:(Int) async throws -> Void) -> Bool {
            //note: build fails with require
            guard case .codedError(let code) = (#expect(throws: ExampleError.self) {
                try function(with)
            }) else { return false }
            return range.contains(code)
        }

ETA: see also Comparing enum cases while ignoring associated values - #24 by tera

I went back and watched last year's "Going Further" WWDC video before I launched in to porting some of my tests, TBH

Thanks for pointing out the other docs below!

1 Like