1

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.

Gene Bogdanovich
  • 773
  • 1
  • 7
  • 21
  • Have you tried `XCTAssertThrowsError(try methodThatThrows())`? [Link](https://stackoverflow.com/questions/32860338/how-to-unit-test-throwing-functions-in-swift). You might want to move `handleWriting` out side and test it separately. But probably no need to test `NSFileCoordinator`, you only need to test the one that will call to `coordinateWriting..` to see how it handle error/success case. – Tj3n Jul 26 '22 at 10:51
  • Do you have any updates on this? I'm also writing an async interface to NSFileCoordinator but was going to use the newer API https://developer.apple.com/documentation/foundation/nsfilecoordinator/1411533-coordinate – malhal Jun 28 '23 at 13:41

1 Answers1

1

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.

Dávid Pásztor
  • 51,403
  • 9
  • 85
  • 116