iosswiftunit-testingnsfilecoordinatornsfilepresenter

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


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.


Solution

  • While testing using protocols, as David Pasztor suggested, is a standard solution, I didn't see it fit in this situation. I created a subclass of NSFileCoordinator and overridden the methods I wanted tested to throw fake errors.

    final class AsyncFileCoordinatorTests: XCTestCase {
        
        class NSFileCoordinatorStub: NSFileCoordinator {
            
            override func coordinate(writingItemAt url: URL, options: NSFileCoordinator.WritingOptions = [], error outError: NSErrorPointer, byAccessor writer: (URL) -> Void) {
                let error = NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError, userInfo: nil)
                outError?.pointee = error
            }
        }
    }
    
    

    I then used that stub subclass in my unit tests.

    final class AsyncFileCoordinatorTests: XCTestCase {
    
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        
        func testWriteSimulatedError() async throws {
            let data = Data("testData".utf8)
            let fileName = "write"
            
            let url = documentDirectory.appendingPathComponent(fileName)
            
            var error: Error?
            
            do {
                try await NSFileCoordinatorStub().coordinate(writing: data, at: url)
            } catch let err {
                error = err
            }
            
            XCTAssertNotNil(error)
        }
    
    }
    
    

    Also, I removed the singleton design and put my asynchronous method wrappers directly on the NSFileCoordinator class.

    extension NSFileCoordinator {
        // func coordinate(writingItemAt url: URL, options: NSFileCoordinator.WritingOptions = [], error outError: NSErrorPointer, byAccessor writer: (URL) -> Void) {
        func coordinate(writing data: Data, at url: URL) async throws {
            try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
                
                func handleWriting(newURL: URL) {
                    do {
                        try data.write(to: newURL, options: .atomic)
                        continuation.resume()
                        return
                    } catch {
                        continuation.resume(throwing: error)
                        return
                    }
                }
                
                var nsError: NSError?
                
                self.coordinate(writingItemAt: url, options: .forReplacing, error: &nsError, byAccessor: handleWriting)
                if let nsError = nsError {
                    continuation.resume(throwing: nsError)
                    return
                }
            }
        }
    }
    
    

    You can check my full implementation along with the tests here.