Using a Protocol to Facilitate Mocking Services for Testing

In the refactored code, we use protocol-oriented programming to define a contract for file fetching services. This makes it easy to replace the real implementation (FileService) with a mock implementation (MockFileService) when writing unit tests.

1. Why Use a Protocol?

A protocol allows us to:

  • Abstract dependencies: Instead of tightly coupling ViewModel to FileService, we define a protocol FileServiceProtocol.
  • Easily swap implementations: We can inject different implementations (real or mock) without modifying the ViewModel code.
  • Enable unit testing: In tests, we can use a mock version of FileService to return predefined data.

2. How It Works in the Code

Step 1: Define a Protocol

protocol FileServiceProtocol {
    func fetchFiles(withPrefix prefix: String) -> [String]
}        

  • This ensures that any class conforming to FileServiceProtocol must implement fetchFiles(withPrefix:).

Step 2: Implement the Protocol in the Real Service

class FileService: FileServiceProtocol {
    func fetchFiles(withPrefix prefix: String) -> [String] {
        let fm = FileManager.default
        guard let path = Bundle.main.resourcePath else { return [] }
        
        do {
            let files = try fm.contentsOfDirectory(atPath: path)
            return files.filter { $0.hasPrefix(prefix) }
        } catch {
            print(AppError.fileNotFound.localizedDescription)
            return []
        }
    }
}        

  • This implementation reads files from the app bundle.

Step 3: Inject the Dependency into the ViewModel

class ImageListViewModel {
    private let fileService: FileServiceProtocol
    private(set) var listOfFiles: [String] = []        
    init(fileService: FileServiceProtocol = FileService()) {
        self.fileService = fileService
    }    func loadImages() {
        listOfFiles = fileService.fetchFiles(withPrefix: "nssl")
    }
}        

  • fileService is injected, so ImageListViewModel does not depend on a concrete class (FileService). Instead, it depends on an abstraction (FileServiceProtocol).

3. Creating a Mock Service for Testing

For unit testing, we need to replace the real file service with a mock service that returns predefined data.

class MockFileService: FileServiceProtocol {
    func fetchFiles(withPrefix prefix: String) -> [String] {
        return ["nssl001.jpg", "nssl002.jpg", "nssl003.jpg"]
    }
}        

  • This mock service does not access the filesystem.
  • It simply returns test data, making tests predictable and faster.

4. Writing a Unit Test

We can now write a unit test for ImageListViewModel using the mock service.

class ImageListViewModelTests: XCTestCase {
    
    func testLoadImages() {
        // Arrange: Use MockFileService instead of FileService
        let mockService = MockFileService()
        let viewModel = ImageListViewModel(fileService: mockService)        // Act: Load images
        viewModel.loadImages()        // Assert: Check if mock data is correctly loaded
        XCTAssertEqual(viewModel.listOfFiles.count, 3)
        XCTAssertEqual(viewModel.listOfFiles[0], "nssl001.jpg")
    }
}        

5. Benefits of This Approach

Faster Tests — No real file system access, just predefined data. More Reliable — Tests won’t fail due to external file changes. Easier Debugging — You control the test data, making failures easier to analyze. Better Maintainability — You can change FileService implementation without affecting the ViewModel.

要查看或添加评论,请登录

M.Shahzad Qamar的更多文章