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.