Search code examples
swiftcore-dataxctest

XCTest only: No NSEntityDescriptions in any model claim the NSManagedObject subclass


Let me outline the relevant code:

I have a DataManager class as follows:

enum DataManagerType {
    case normal, preview, testing
}

class DataManager: NSObject, ObservableObject {

    static let shared = DataManager(type: .normal)
    static let preview = DataManager(type: .preview)
    static let testing = DataManager(type: .testing)

    @Published var todos = [Todo]()

    fileprivate var managedObjectContext: NSManagedObjectContext
    private let todosFRC: NSFetchedResultsController<TodoMO>

    private init(type: DataManagerType) {
        switch type {
        case .normal:
            let persistentStore = PersistentStore()
            self.managedObjectContext = persistentStore.context
        case .preview:
            let persistentStore = PersistentStore(inMemory: true)
            self.managedObjectContext = persistentStore.context
            for i in 0..<10 {
                let newTodo = TodoMO(context: managedObjectContext)
                newTodo.title = "Todo \(i)"
                newTodo.isComplete = false
                newTodo.date = Date()
                newTodo.id = UUID()
            }
            try? self.managedObjectContext.save()
        case .testing:
            let persistentStore = PersistentStore(inMemory: true)
            self.managedObjectContext = persistentStore.context
        }

        let todoFR: NSFetchRequest<TodoMO> = TodoMO.fetchRequest()
        todoFR.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
        todosFRC = NSFetchedResultsController(fetchRequest: todoFR,
                                              managedObjectContext: managedObjectContext,
                                              sectionNameKeyPath: nil,
                                              cacheName: nil)
        super.init()

        // Initial fetch to populate todos array
        todosFRC.delegate = self
        try? todosFRC.performFetch()
        if let newTodos = todosFRC.fetchedObjects {
            self.todos = newTodos.map({todo(from: $0)})
        }
    }

    func saveData() {
        if managedObjectContext.hasChanges {
            do {
                try managedObjectContext.save()
            } catch let error as NSError {
                NSLog("Unresolved error saving context: \(error), \(error.userInfo)")
            }
        }
    }

    private func fetchFirst<T: NSManagedObject>(_ objectType: T.Type, predicate: NSPredicate?) -> Result<T?, Error> {
        let request = objectType.fetchRequest()
        request.predicate = predicate
        request.fetchLimit = 1
        do {
            let result = try managedObjectContext.fetch(request) as? [T]
            return .success(result?.first)
        } catch {
            return .failure(error)
        }
    }
}

My persistence store is as such:

struct PersistentStore {

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "CoreDataModel")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }

    var context: NSManagedObjectContext { container.viewContext }

    func saveContext () {
        if context.hasChanges {
            do {
                try context.save()
            } catch let error as NSError {
                NSLog("Unresolved error saving context: \(error), \(error.userInfo)")
            }
        }
    }
}

I get an error when calling the following in an XCTest:

let predicate = NSPredicate(format: "id = %@", todo.id as CVarArg) //todo.id is just some UUID() //irrelevant here
let result = fetchFirst(TodoMO.self, predicate: predicate)

This is the error I get:

2022-07-09 21:36:17.425709-0400 CoreDataExample[73965:7495035] [error] error: No NSEntityDescriptions in any model claim the NSManagedObject subclass 'CoreDataExampleTests.TodoMO' so +entity is confused. Have you loaded your NSManagedObjectModel yet ? CoreData: error: No NSEntityDescriptions in any model claim the NSManagedObject subclass 'CoreDataExampleTests.TodoMO' so +entity is confused. Have you loaded your NSManagedObjectModel yet ? 2022-07-09 21:36:17.425780-0400 CoreDataExample[73965:7495035] [error] error: +[CoreDataExampleTests.TodoMO entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass CoreData: error: +[CoreDataExampleTests.TodoMO entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass /Users/santiagogarciasantos/Documents/Xcode Projects/CoreDataExample/CoreDataExample/DataManager/DataManager.swift:87: error: -[CoreDataExampleTests.CoreDataExampleTests test_Add_Todo] : executeFetchRequest:error: A fetch request must have an entity. (NSInvalidArgumentException)

Following other solutions on here, I've checked the target, made sure my Entity is in "Current Product Module" but it still won't work.

Important: This only occurs when I'm in using XCTest (using my DataManager.testing), not in Previews or in simulator

Here's a link to a demo project I've made which recreates the issue.

Thanks for your help!


Solution

  • I made a couple of changes to your code to make the tests work:

    1. you should not add your app classes to the test target. They are imported automatically by selecting a "Host Application" in you test target and made accessible by the directive @testable import CoreDataExample.

    2. when the test execute, an instance of CoreDataExampleApp is created. This in turn instantiates var dataManager = DataManager.shared.
      Then the tests execute where you instantiate dataManager = DataManager.testing.
      Two instances of PersistentStore are created which have their own version of the core data stack including the managedObjectModel.
      Those models are fighting over your NSManagedObject subclasses which results in objectType.fetchRequest() having no entity.

    To fix your issue, go through all files in your app target and make sure in FileInspector>TargetMembership only CoreDataExample is checked. Then in CoreDataExampleTests change line 17 to dataManager = DataManager.shared.

    Your tests will run now.

    If you want to keep your different DataManager flavours, you have to make sure that only one instance of PersistentStore is ever created. One simple way would be to make it static:

    class DataManager: NSObject, ObservableObject {
    
        static let persistentStore = PersistentStore()
    
        // [...]
    
        private init(type: DataManagerType) {
            switch type {
            case .normal:
                self.managedObjectContext = DataManager.persistentStore.context
        
        // etc.
    

    Edit: Alternative Solution

    The issue comes from using the TestTarget>General>'Host Application' feature in the first place, which Xcode sets now for new projects. This is meant for Application Tests where you need an app instance.
    If you instead want to perform Logic Tests you should opt out of this feature. You then don't get the app instance with its side effects. In your case this is probably what you want. The tests will also run faster because the app does not need to be loaded.
    But you then need to add all your files manually to your test target.

    To use the alternative approach:

    1. deselect "Host Application"
      host
    2. Manually add all relevant files
      membership
    3. You have to help PersistentStore finding the right model file by loading it from an explicit URL. Change this in your test class:
       override func setUp() {
           super.setUp()
           let testBundle = Bundle(for: type(of: self))
           let modelUrl = testBundle.url(forResource: "CoreDataModel", withExtension: "momd")
           dataManager = DataManager(type: .testing, modelUrl: modelUrl)
       }
      
      Change your DataManager and PersistentStore accordingly to handle the optional URL:
      // DataManager change not shown (just pass through)
      
      struct PersistentStore {
          init(inMemory: Bool = false, modelUrl: URL? = nil) {
              if let url = modelUrl {
                  let mom = NSManagedObjectModel(contentsOf: url)! // todo: handle !
                  container = NSPersistentContainer(name: "CoreDataModel", managedObjectModel: mom) // todo: maybe get name from URL
              } else {
                  container = NSPersistentContainer(name: "CoreDataModel")
              }
      

    I recommend this second approach, I consider it much cleaner.
    If you also need Application Tests, add an additional target.