Search code examples
iosswiftunit-testingmobilexctestcase

How should write the test case for the code below?


If this code is not suitable for write test code, how should modify the code for writing test case?

class MyFileManager {
   static let shared = MyFileManager()
 
  func isStored(atPath path: String) -> Bool {
     return FileManager.default.fileExists(atPath: path)
 }

 func readData(atPath path: String) -> Data? {
      return try? Data(contentsOf: URL(fileURLWithPath: path))
  }
}

class SomeViewModel {
  func getCachedData() -> Data? {
      let path = "xxxxx"
 
      if MyFileManager.shared.isStored(atPath: path) {
          return MyFileManager.shared.readData(atPath: path)
      } else {
          return nil
      }
  }
}

class TestSomeViewModel: XCTestCase {
  func testGetCachedData() {
      let viewModel = SomeViewModel()
      // Need to cover SomeViewModel.getCachedData() method
  }
}

Solution

  • Consider extracting the methods of the class into a separate protocol, so that we can make both the actual class and the mock class conform to that protocol, and we can test out the intended functionalities in the unit tests instead of executing the code in the actual implementation.

    /*
        Extract the 2 methods of MyFileManager into a separate protocol.
        Now we can create a mock class which also conforms to this same protocol,
        which will help us in writing unit tests.
    */
    protocol FileManagerProtocol {
        func isStored(atPath path: String) -> Bool
        func readData(atPath path: String) -> Data?
    }
    
    class MyFileManager: FileManagerProtocol {
        static let shared = MyFileManager()
        
        // To make a singleton instance, we have to make its initializer private.
        private init() {
        }
        
        func isStored(atPath path: String) -> Bool {
            //ideally, even FileManager.default instance should be "injected" into this class via dependency injection.
            return FileManager.default.fileExists(atPath: path)
        }
        
        func readData(atPath path: String) -> Data? {
            return try? Data(contentsOf: URL(fileURLWithPath: path))
        }
    }
    

    The SomeViewModel class can also get its dependencies via dependency injection.

    class SomeViewModel {
        var fileManager: FileManagerProtocol?
        
        // We can now inject a "mocked" version of MyFileManager for unit tests.
        // This "mocked" version will confirm to FileManagerProtocol which we created earlier.
        init(fileManager: FileManagerProtocol = MyFileManager.shared) {
            self.fileManager = fileManager
        }
        
        /*
            I've made a small change to the below method.
            I've added the path as an argument to this method below,
            just to demonstrate the kind of unit tests we can write.
        */
        func getCachedData(path: String = "xxxxx") -> Data? {
            if let doesFileExist = self.fileManager?.isStored(atPath: path),
               doesFileExist {
                return self.fileManager?.readData(atPath: path)
            }
            return nil
        }
    }
    

    The unit tests for the above implementation can look something similar to what is written below.

    class TestSomeViewModel: XCTestCase {
        var mockFileManager: MockFileManager!
        
        override func setUp() {
            mockFileManager = MockFileManager()
        }
        
        override func tearDown() {
            mockFileManager = nil
        }
        
        func testGetCachedData_WhenPathIsXXXXX() {
            let viewModel = SomeViewModel(fileManager: self.mockFileManager)
            XCTAssertNotNil(viewModel.getCachedData(), "When the path is xxxxx, the getCachedData() method should not return nil.")
            XCTAssertTrue(mockFileManager.isStoredMethodCalled, "When the path is xxxxx, the isStored() method should be called.")
            XCTAssertTrue(mockFileManager.isReadDataMethodCalled, "When the path is xxxxx, the readData() method should be called.")
        }
        
        func testGetCachedData_WhenPathIsNotXXXXX() {
            let viewModel = SomeViewModel(fileManager: self.mockFileManager)
            XCTAssertNil(viewModel.getCachedData(path: "abcde"), "When the path is anything apart from xxxxx, the getCachedData() method should return nil.")
            XCTAssertTrue(mockFileManager.isStoredMethodCalled, "When the path is anything apart from xxxxx, the isStored() method should be called.")
            XCTAssertFalse(mockFileManager.isReadDataMethodCalled, "When the path is anything apart from xxxxx, the readData() method should not be called.")
        }
    }
    
    // MockFileManager is the mocked implementation of FileManager.
    // Since it conforms to FileManagerProtocol, we can implement the
    // methods of FileManagerProtocol with a different implementation
    // for the assertions in the unit tests.
    class MockFileManager: FileManagerProtocol {
        private(set) var isStoredMethodCalled = false
        private(set) var isReadDataMethodCalled = false
        
        func isStored(atPath path: String) -> Bool {
            isStoredMethodCalled = true
            if path.elementsEqual("xxxxx") {
                return true
            }
            return false
        }
        
        func readData(atPath path: String) -> Data? {
            isReadDataMethodCalled = true
            if path.elementsEqual("xxxxx") {
                return Data()
            }
            return nil
        }
    }
    

    Feel free to copy-paste all the above classes and the unit tests to a separate playground file. To run both the unit tests in Playground, write -

    TestSomeViewModel.defaultTestSuite.run()
    

    Some other things to keep in mind :-

    1. It's recommended to first write the unit test, run it and see it fail, then write the minimum amount of code needed to pass the unit test. This is called Test Driven Development.
    2. It's easier to write tests if all the implementation classes use Dependency Injection.
    3. Consider avoiding singletons. If singletons are not used carefully, they make the code difficult to unit-test. Feel free to read more about why we should use singletons sparingly here and here.