Feb 28, 2019

A case against assertionFailure

blogpost author photo
Tomasz Bąk
blogpost cover image
What has this poor function ever done to you, making you run a CASE against it? Let’s make it clear. assertionFailure is only one of the many offenders.

The title of this article should actually read: “A case against misuse of the assert family of functions in favor of Swift’s modern type system and unit tests.”

Assertions

Assertion is a function used to check the the condition in the run-time. If the condition is false, the application is terminated to avoid further damage in a broken, undefined state. In Swift, assertions are represented by four functions:

func assert(() -> Bool, () -> String, file: StaticString, line: UInt)
func assertionFailure(() -> String, file: StaticString, line: UInt)
func precondition(() -> Bool, () -> String, file: StaticString, line: UInt)
func preconditionFailure(() -> String, file: StaticString, line: UInt) -> Never

They can be found under Swift > Swift Standard Library > Debugging and Reflection

Asserts are ignored by the compiler when a release version is built. Preconditions are executed regardless of the environment. Methods with “failure” suffix are convenience functions that skip checking a condition and just instantly fail. You can use asserts like this:

func buildReport(for month: Int) -> Report {
   assert(month >= 1 && month <= 12)
   // continue with building the report
}

func register(person: Person?) {
  guard let thePerson = person else {
    preconditionFailure("Should never happen")
  }
  // continue registration
}

Defensive programming

As we progress in our profession, we learn that “print(error) is not error handling”. The next tool we reach for is the assert. The urge to use asserts comes from a very good place — a place called Defensive programming. Defensive programming is a methodology that protects us from the simple fact that we are human. We have moods, we make mistakes, and we are very, very bad at handling complexity. Asserts are used to build assumptions and by that reduce the complexity of our software.

Legacy

In my opinion, the strongest argument in the case lies right there — in the documentation. When you open the description of assert you are greeted with a simple phrase that you just can’t miss: “Performs a traditional C-style assert”. Swift is a forward-looking language and the Core Team have shown on a couple of occasions that they are ready to cut features in the name of progress. It was not so long ago (in Swift 3.0), that syntax for “C-style for loops” was removed from the language. The problem with legacy bits like those is that they were designed with a different technical environment in mind that may no longer be valid. Nothing is forever.

Silent fails

AssertionFailure is mentioned explicitly, because it is very dangerous when used incorrectly.

func updateState(parameter: Int?) {
  guard let theParameter = parameter else {
    assertionFailure("Error: missing parameter")
    return
  }
  // continue updating
}

We use the function to represent an impossible state. We check our paths in the debug mode and if everything works, we move on. The truth is that no one is capable of checking all the paths our software goes through. So, if we miss some cases, assertionFailure will never trigger in the production, becoming even less useful then print(error). This may leave the app in an undefined state that is dangerous to the user’s data. A phenomenon like this is called silent fail. A system doesn’t work, but you don’t actually know about it. Despite that it never crashed and may go rogue.

Testability

So, to prevent a silent fail, you can use preconditionFailure instead.

func updateState(parameter: Int?) {
  guard let theParameter = parameter else {
    preconditionFailure(“Error: missing parameter”)
  }
  // continue updating
}

This makes one problem go away, but leaves a few others. Trying to unit test an impossible state crashes your test suite.

func testUpdateStateWithImposibleState() {
   system.updateState(parameter: nil) // crash!
   XCTAssertEqual(system.state, expected)
}

This hurts the confidence in the tests. A much better solution would be using a throw instead of preconditionFailure. Both options are testable, pushing error handling one layer up in the system.

func updateState(parameter: Int?) throws {
  guard let theParameter = parameter else {
    throw StateError.MissingInput
  }
  // continue updating
}

Thanks to this you can write a test:

func testUpdateStateWithImposibleState() {
  XCTAssertThrowsError(try system.updateState(parameter: nil)) { (error) -> Void in
        XCTAssertEqual(error as? StateError, StateError.MissingInput)
    }
}

Strong type system

Swift offers a much better tool to tackle the problem of complexity. The name of the tool is “strong typing”. It’s a fancy name for bundling together a bunch of values and giving them a name. The name and the structure is then enforced by the compiler in the compile-time. Using your own types creates assumptions just like assertions, but is much safer, and you don’t have to write tests for impossible states, because they would not even compile. Take a look at an improved version of the buildReport function introduced at the beginning of the article:

func buildReport(for month: Month) -> Report {
   // no need for assert!
   ...
}

struct Month {
  var value: Int
  init?(value: Int) {
    guard month >= 1 && month <= 12 else {
      return nil
    }
    self.value = value
}

The logic that was previously hidden inside the assert is now extracted to a struct and can have a test written for it.

Summary

If you must remember just one thing from the post, make it this: “Make NOT using asserts your default.” Strong types, throw and optionals are much better tools available in Swift to tackle the same problem that assert tries to deal with, but making your code safer and more testable. This path is definitely not easy, but I promise you: it is simple.