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.
You shouldn't directly be unit testing
NSFileCoordinator
. Instead, you should create a protocol thatNSFileCoordinator
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 yourFileCoordinator
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.