【问题标题】:How should write the test case for the code below?下面的代码应该如何编写测试用例?
【发布时间】:2021-10-19 13:38:07
【问题描述】:

如果这段代码不适合编写测试代码,应该如何修改编写测试用例的代码?

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
  }
}

【问题讨论】:

  • 你不能,因为这段代码的编写方式不利于测试。这就是为什么人们总是告诉你先写测试:)
  • 您需要两个测试,因为您需要同时测试文件何时存在和不存在。除非它在其他地方使用,否则如果给定路径中不存在文件,则最好删除函数 isStored 并让 readData 失败。
  • 顺便说一句,与其检查是否存在然后尝试阅读它,不如完全失去isStored 并尝试阅读它。如果文件不存在,那么try? Data(contentsOf:) 将返回nil,无论如何,如果它没有被缓存,这很方便你返回。你去掉一个方法,你去掉getCachedData里面的if语句,等等
  • 如何修改代码以编写测试用例@Alexander
  • @emraz 本质上,您需要使用依赖注入来注入依赖项的模拟变体(例如,使用模拟 FileManager 而不是真正的 FileManager,它实际上并不从磁盘读取/写入,您的测试代码可以与之交互)。尝试将您的实现代码更改为可测试是很困难的。我建议您将这段代码放在一边,重新开始,然后首先编写您的测试代码。一旦你把它作为一个规范,你就可以填写实现。

标签: ios swift unit-testing mobile xctestcase


【解决方案1】:

考虑将类的方法提取到单独的protocol 中,这样我们就可以使实际类和模拟类都符合该协议,并且我们可以在单元测试中测试预期的功能而不是执行实际实现中的代码。

/*
    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))
    }
}

SomeViewModel 类也可以通过依赖注入获取其依赖项。

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
    }
}

上述实现的单元测试看起来类似于下面写的。

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
    }
}

您可以随意将上述所有类和单元测试复制粘贴到单独的 Playground 文件中。要在 Playground 中运行这两个单元测试,请编写 -

TestSomeViewModel.defaultTestSuite.run()

需要记住的其他一些事项:-

  1. 建议先编写单元测试,运行它并看到它失败,然后编写通过单元测试所需的最少代码量。这称为Test Driven Development
  2. 如果所有实现类都使用依赖注入,编写测试会更容易。
  3. 考虑避免单例。如果不小心使用单例,它们会使代码难以进行单元测试。欢迎阅读更多关于为什么我们应该谨慎使用单例 herehere

【讨论】:

  • 美丽(和我会做的完全一样)!一个小说明:由于MyFileManager是引用类型,你也可以使用子类化而不是协议,但我自己更喜欢协议解决方案(因为SimpleViewModel不需要关心它的fileManager是不是一个类或结构)。还加 1 表示单身 可能 是有问题的,而不是完全避开它们。 :)
猜你喜欢
  • 1970-01-01
  • 2023-01-10
  • 1970-01-01
  • 1970-01-01
  • 2013-03-05
  • 2019-09-10
  • 1970-01-01
  • 2021-12-13
  • 2019-08-19
相关资源
最近更新 更多