How to make NSFileCoordinator generate an error in a unit test environment?

313 views Asked by At

I wrote an async interface for the NSFileCoordinator API.

struct FileCoordinator {
    private init() {}
    static let shared = FileCoordinator()
    
    func coordinateWriting(of data: Data, to url: URL) async throws {
        try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<Void, Error>) in
            var error: NSError?
            
            func handleWriting(newURL: URL) {
                do {
                    try data.write(to: newURL, options: .atomic)
                    continuation.resume()
                } catch {
                    continuation.resume(throwing: error)
                    // This is the line that I have been able to test by making this method write to dev null.
                }
            }
            
            NSFileCoordinator().coordinate(writingItemAt: url, options: .forReplacing, error: &error, byAccessor: handleWriting)
            
            // Developer documentation: If a file presenter encounters an error while preparing for this write operation, that error is returned in this parameter and the block in the writer parameter is NOT executed.
            // So in theory, we shouldn’t resume our continuation more than once.
            
            if let error = error {
                continuation.resume(throwing: error)
                // This is the line that I have not been able to test.
            }
        })
    }
}

Now I'm writing unit tests for this logic.

final class FileCoordinatorTests: XCTestCase {
    
    static let testDirectoryURL: URL = {
        var url = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("EssentialHelpersTests", isDirectory: true)
        url = url.appendingPathComponent("FileCoordinatorTests", isDirectory: true)
        
        return url
    }()
    
    override func setUpWithError() throws {
        // Create a directory for this test class.
        try FileManager.default.createDirectory(at: Self.testDirectoryURL, withIntermediateDirectories: true)
    }
    
    override func tearDownWithError() throws {
        // Delete a directory after each test.
        try FileManager.default.removeItem(at: Self.testDirectoryURL)
    }
    
    func test_FileWritingCoordination() async throws {
        // given
        let data = Data("testData".utf8)
        let fileName = UUID().uuidString
        let url = Self.testDirectoryURL.appendingPathComponent(fileName)
        
        // when
        try await FileCoordinator.shared.coordinateWriting(of: data, to: url)
        
        // then
        XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
    }
    
    func test_FileWritingCoordinationWithError() async {
        // given
        let data = Data("testData".utf8)
        let urlWeDoNotHavePermissionToWriteTo = URL(fileURLWithPath: "/dev/null")
        var error: Error?
        
        // when
        do {
            try await FileCoordinator.shared.coordinateWriting(of: data, to: urlWeDoNotHavePermissionToWriteTo)
        } catch let err {
            error = err
        }
        
        // then
        XCTAssertNotNil(error)
    }
}

I can't seem to come up with a way to simulate an error condition for NSFileCoordinator, so it will assign an error object to the pointer we provide. The documentation says the error is created when the file presenter encounters an issue while preparing for the write operation. But I'm not using a file presenter in the first place. I'm using this API to future-proof my code in case I add iCloud support in the future.

The documentation says that if we call cancel() method on the coordinator, the error will be generated. But where do I call that method in the context of my code? I tried calling it after the call to coordinate(writingItemAt:options:writingItemAt:options:error:byAccessor:) but that has no effect.

I fear that if there is an error, my code structure could cause continuation misuse (resuming twice). Even though the block that handles file operation does not execute if there is an error (according to documentation), I have no way to confirm that.

1

There are 1 answers

0
Dávid Pásztor On

You shouldn't directly be unit testing NSFileCoordinator. Instead, you should create a protocol that NSFileCoordinator conforms to and inject a mock conforming to the same protocol for your unit tests. This will allow you to control the desired behaviour of your dependency in unit tests and test that your FileCoordinator correctly behaves under certain conditions of the dependency.

You also shouldn't be using singletons, since those make dependency injection and hence unit testing much harder.