Survival Swift: Injecting Static Classes

Posted by Grego on November 27, 2021

Static classes can be a pain to mock for testing. A common pattern I’ve seen to handle this is to extract a protocol from your class and inject your test subject with a sensible default value in its initializer.

Original Code

Let’s use our File class from the previous file example. To keep this brief I’ll just show how we could test some of its functions. The original is written like:

enum File {
  static func object<T: Decodable>(from file: String) -> T? {
    guard let data = data(from: file) else {
      return nil
    }

    return decode(data: data)
  }

  // ... other methods, including `decode(data:)` helper ...

}

Test Subject

Here is a contrived sample usage for the object(from:) method. This class will be our test subject:

/// Sample code using the `File` class
final class FileSample {
  func getDogLinkFromFile() -> String? {
    let dogPhoto: DogPhoto? = file.object(from: File.Sample.dogFile)
    return dogPhoto?.photoUrl
  }
}

Extract Protocol

protocol FileReader {
  static func object<T: Decodable>(from file: String) -> T?
}

Conform to Protocol

enum File: FileReader {
    // ...
}

Inject the Protocol’s Type

/// Sample code using the `File` class
final class FileSample {
  // 1. store type as property
  private var fileReader: FileReader.Type

  // 2. inject type, assigning a sensible default for production code
  init(fileReader: FileReader.Type = File.self) {
    self.fileReader = fileReader
  }

  func getDogLinkFromFile() -> String? {
    // 3. use property instead of type directly
    let dogPhoto: DogPhoto? = fileReader.object(from: File.Sample.dogFile)
    return dogPhoto?.photoUrl
  }
}

Key points here are using Protocol.Type to create a type parameter and ClassName.self to pass the class as a class rather than an instance.

Test Case

Now we can substitute a mock:

class FileSampleTest: XCTestCase {
  /// test subject
  var fileSample: FileSample!

  /// FileReader implementation to use for test, not strictly necessary as a proeprty
  var fileReader: TestFileReader.Type!

  let dogPhoto: DogPhoto = DogPhoto(status: "status", photoUrl: "photoUrl")

  override func setUpWithError() throws {
    fileReader = TestFileReader.self
    // 1. Inject test class
    fileSample = FileSample(fileReader: fileReader)
  }

  override func tearDownWithError() throws {
    // 2. reset static class state
    fileReader.object = nil
  }


  func test_getUrlDogPhotoFromFile_returnsUrlString() {
    // 3. Inject test return value
    fileReader.object = dogPhoto

    // 4. assert expectations using test subject normally
    XCTAssertEqual("photoUrl", fileSample.getDogLinkFromFile())
  }
}

/// Testable FileReader implementation
enum TestFileReader: FileReader {
  /// store object as Decodable? since using generics on static properties is not possible
  static var object: Decodable?

  static func object<T>(from file: String) -> T? {
    // casually cast our Decodable to T if possible.
    // This is hacky but will suffice for test code where scope is limited
    object as? T
  }
}

The TestFileReader class is a bit hacky as noted, to work around the limitations of generics. However, this hackiness is permissible since our test cases are mostly very short, simple, methods that test one specific thing only and have very limited scope.