Swift Testing Core Data setup/teardown

Fully aware this might be user error, so grateful for any advice here - I'm trying to chase down an issue that I'm having with my CoreData model during testing in Swift Testing. The below only occurs when using Swift Testing, and runs fine, along with hundreds of other tests in XCTest, only swapping out #expectations for XCTestAssert statements.

I have a data model TopicEntity with a to-many relationship to TopicPageEntity, that has a to-many relationship to TopicPageAssetEntity. When running a simple test to test saving and loading from my persistence manager, I get the below exception when initialising my entities.

Thread 1: "Unacceptable type of value for to-one relationship: property = \"page\"; desired type = TopicPageEntity; given type = TopicPageEntity; value = <TopicPageEntity: 0x6000021adea0> (entity: TopicPageEntity; id: 0x6000006481a0 <x-coredata:///TopicPageEntity/t34C7860B-7B89-4A41-8904-7F5A5420B85E1470>; data: {\n    assets =     (\n        \"0x600000648000 <x-coredata:///TopicPageAssetEntity/t34C7860B-7B89-4A41-8904-7F5A5420B85E1471>\"\n    );\n    id = nil;\n    pageNumber = nil;\n    title = nil;\n    topic = nil;\n    topicID = nil;\n})."

This does not occur when running with XCTest, and searching Stack Overflow reveals similar issues where there is more than one instance of something (not sure what exactly) in the CoreData stack at the same time. There is a shared instance of CoreDataManager running in the test target during testing.

I've always written my XCTests to setup and teardown the core data manager to avoid this problem.

    override func setUpWithError() throws {
        coreDataManager = .preview
    }

    override func tearDownWithError() throws {
        coreDataManager = nil
    }

However, with Swift Testing, using a struct, I don't have that option, and I'm curious what the best practice should be here for writing Swift Tests.

I'm creating my entities as follows. Yes I know that I may be assigning relationships more than once - this is my attempt so far at clearing up the error before I realised it was only happening during Swift Tests:

extension TopicEntity {
    convenience init(context: NSManagedObjectContext, topic: Topic) {
        self.init(context: context)
        for page in topic.pages {
            let pageEntity = TopicPageEntity(context: context, page: page, topicEntity: self)
            addToPages(pageEntity)
        }

        update(context, topic: topic)
    }

    static func fetchRequest(for topicId: String) -> NSFetchRequest<TopicEntity> {
        let fetchRequest = TopicEntity.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "id == %@", topicId)

        return fetchRequest
    }

    func update(_ context: NSManagedObjectContext, topic: Topic) {
        id = topic.id
        name = topic.name
        iconId = topic.iconId
        showInAppNavigation = topic.showInAppNavigation
        updatedAt = topic.updatedAt
        updatePages(context: context, in: topic)
    }

    func updatePages(context: NSManagedObjectContext, in topic: Topic) {
        guard let pageEntities = pages?.array as? [TopicPageEntity] else { return }

        for entity in pageEntities {
            guard let page = topic.pages.first(where: { page in
                page.id == entity.id
            }) else {
                removeFromPages(entity)
                continue
            }
            entity.update(context: context, page: page)
        }

        for page in topic.pages {
            guard !pageEntities.contains(where: { $0.id == page.id
            }) else { return }

            let pageEntity = TopicPageEntity(context: context, page: page, topicEntity: self)
            addToPages(pageEntity)
        }
    }
}

extension TopicSectionEntity {
    convenience init(context: NSManagedObjectContext, section: TopicSection) {
        self.init(context: context)
        update(section: section)
    }

    func update(section: TopicSection) {
        title = section.title
        startingPageNumber = Int64(section.startingPageNumber)
        endingPageNumber = Int64(section.endingPageNumber)
    }
}

extension TopicPageEntity {
    convenience init(context: NSManagedObjectContext, page: TopicPage, topicEntity: TopicEntity) {
        self.init(context: context)
        self.topic = topicEntity // exception thrown here

        for asset in page.assets {
            let assetEntity = TopicPageAssetEntity(context: context, asset: asset, pageEntity: self)
           assetEntity.page = self
            addToAssets(assetEntity)
        }

        update(context: context, page: page)
    }

    func update(context: NSManagedObjectContext, page: TopicPage) {
        id = page.id
        pageNumber = Int64(page.pageNumber)
        title = page.title
        topicID = page.topicID

        updateAssets(context: context, in: page)
    }

    private func updateAssets(context: NSManagedObjectContext, in page: TopicPage) {
        guard let assetEntities = assets?.array as? [TopicPageAssetEntity] else { return }
        for entity in assetEntities {
            guard let asset = page.assets.first(where: { asset in
                asset.filename == entity.filename &&
                    asset.url == entity.url &&
                    asset.type.rawValue == entity.type
            }) else {
                removeFromAssets(entity)
                continue
            }
            entity.update(asset: asset)
        }

        for asset in page.assets {
            guard !assetEntities.contains(where: {
                $0.filename == asset.filename &&
                    $0.url == asset.filename &&
                    $0.type == asset.type.rawValue
            }) else { return }

            let assetEntity = TopicPageAssetEntity(context: context, asset: asset, pageEntity: self)
            assetEntity.page = self
            addToAssets(assetEntity)
        }
    }
}

extension TopicPageAssetEntity {
    convenience init(context: NSManagedObjectContext, asset: Asset, pageEntity: TopicPageEntity) {
        self.init(context: context)
       self.page = pageEntity
        update(asset: asset)
    }

    func update(asset: Asset) {
        filename = asset.filename
        url = asset.url
        type = asset.type.rawValue
    }
}

The problem test is below, though it appears to occur inconsistently in other Swift Tests that touch CoreDataManager:

struct CoreDataManagerSwiftTests {

    var coreDataManager: CoreDataManager = .preview
    
    @Test func saveNewTopic_shouldSave() async throws {
        let user = User.mock
        
        let topic = Topic.mock
        
        try await coreDataManager.save(user)
        try await coreDataManager.save(topic, for: user.id)
        
        let results = try coreDataManager.context.fetch(TopicEntity.fetchRequest())
        
        #expect(results.count == 1)
        let storedTopic = try results.first!.toDomainModel()
        
        #expect(storedTopic == topic)
    }
    
}

Hi @benfrearson, this is most likely happening because Swift Testing runs its tests in parallel by default, and further runs parallel tests in the same process. Whereas XCTest runs parallel tests in separate processes. That made it a lot easier to write tests that can run in parallel since they are fully separate.

Have you tried applying the .serialized trait to all of your @Tests and @Suites and see if that fixes the problem? If it does, then that definitely means you have tests accessing global state that is bleeding over from test to test.

The fix is either to run all of your tests in serial, or to update your code to no longer depend on global state like that. Most likely it's your CoreDataManager, and in particular the .preview value. How are they defined?

thanks @mbrandonw. I did try marking the suite as .serialized to no avail, but I do have another suite that uses CoreDataManager, so I will try adding it all all Swift Tests.

CoreDataManager is defined like this, where shared is the instance used in the development target, and preview is used for testing:

class CoreDataManager {
    static let shared = CoreDataManager()
    let logger = CGLogger(subsystem: Bundle.main.bundleIdentifier!, category: "CoreDataManager")

    let container: NSPersistentContainer
    let context: NSManagedObjectContext

    /// An initializer to load Core Data, optionally able
    /// to use an in-memory store.
    private init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Contracts")

        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }

        container.loadPersistentStores { _, error in
            if let error = error {
                fatalError("Error: \(error.localizedDescription)")
            }
        }

        context = container.viewContext
    }

    /// Used for in-memory testing only
    static var preview: CoreDataManager {
        CoreDataManager(inMemory: true)
    }
}

Just to clarify, you serialized one test suite, and when you run just that one suite in isolation you still have the problem?

No, sorry I omitted to say that the test does run fine in isolation! :man_facepalming:

So to clarify: the test runs fine on its own. In combination with other tests (including another Swift Testing suite that does use CoreDataManager), it fails.

It sounds like the solution is to serialize all tests. I'm not at my mac for the rest of the day, but I will try this tomorrow and report back. Thanks for your help so far!

Really the solution is to use a separate CoreDataManager for each test, configured in such a way as to be independent from all other instances. This includes the file it persists to (or operating in memory) and any other shared configured. I usually just create those values in the test itself.

@mbrandonw Have tested a bit more:

  • Two test suites decorated with @Suite(.serialized) each containing an identical test that accesses CoreDataManager.preview throws the exception
  • One test suite with two tests that are not serialized throws the exception
  • One test suite with two tests that are serialized passes

Clearly there's an issue with how I'm accessing the in memory CoreData store, but I'm not really sure where to go to get around it.

@Jon_Shier Could you expand on this more? I don't believe it's the CoreDataManager itself that's the problem, but some other shared object probably belonging to NSPersistentContainer or NSManagedObjectContext that's being accessed by both instances of the manager. Having more instances doesn't seem like it's going to solve things... I've tried appending a UUID string to the "/dev/null" path for the persistentStoreDescription, but that didn't change anything. Would be grateful for any pointers!

It's a little tricky, but two separate @Suite(.serialized)s will each run in serial but collectively run in parallel. You can see that by running this:

@Suite(.serialized)
struct Suite1 {
  @Test
  func example1() async throws { try await Task.sleep(for: .seconds(1)) }
}
@Suite(.serialized)
struct Suite2 {
  @Test
  func example2() async throws { try await Task.sleep(for: .seconds(1)) }
}

If you run this you will see that the test runs in only 1 seconds, showing that both sleeps are happening in parallel.

What you need is a single shared suite that is serialized, so that every sub-suite added to it will be run in parallel too. You can do that like this:

// Can go in a separate file or even a shared library.
@Suite(.serialized)
struct SharedSuite {}

extension SharedSuite {
  @Suite
  struct Suite1 {
    @Test
    func example1() async throws { try await Task.sleep(for: .seconds(1)) }
  }
}
extension SharedSuite {
  @Suite
  struct Suite2 {
    @Test
    func example2() async throws { try await Task.sleep(for: .seconds(1)) }
  }
}

Now the test suite takes 2 seconds to pass because everything is serialized. And adding sub-suites inside an extension SharedSuite allows those suites to be in their own files.

I think that should get everything passing consistently, but of course a lot slower since the tests do not run in parallel. It definitely does seem like there's some shared global state getting in the way, but it is strange because your preview manager is created fresh for each test, and it's in-memory. Can you try boiling this down to a minimal reproducing project and share that?

1 Like

Yep will try and get a demo project together when I have a bit more time later today/tomorrow.

Did a bit more digging. Here's the log for running two serialized test suites:

Test Suite 'Selected tests' passed at 2024-10-08 14:56:26.311.
	 Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.001) seconds
β—‡ Test run started.
↳ Testing Library Version: 94 (arm64-apple-ios13.0-simulator)
β—‡ Suite CoreDataManagerSwiftTests2 started.
β—‡ Suite CoreDataManagerSwiftTests started.
β—‡ Test saveNewTopic_shouldSave() started.
β—‡ Test initFromEntity_shouldPreserveData2() started.
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'UserEntity' so +entity is unable to disambiguate.
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'UserEntity' so +entity is unable to disambiguate.
warning:  	 'UserEntity' (0x60000350a310) from NSManagedObjectModel (0x60000212d4f0) claims 'UserEntity'.
CoreData: warning:  	 'UserEntity' (0x60000350a310) from NSManagedObjectModel (0x60000212d4f0) claims 'UserEntity'.
warning:  	 'UserEntity' (0x600003516b50) from NSManagedObjectModel (0x60000215a4e0) claims 'UserEntity'.
CoreData: warning:  	 'UserEntity' (0x600003516b50) from NSManagedObjectModel (0x60000215a4e0) claims 'UserEntity'.
warning:  	 'UserEntity' (0x600003506cb0) from NSManagedObjectModel (0x60000215d0e0) claims 'UserEntity'.
CoreData: warning:  	 'UserEntity' (0x600003506cb0) from NSManagedObjectModel (0x60000215d0e0) claims 'UserEntity'.
error: +[UserEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[UserEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'BranchEntity' so +entity is unable to disambiguate.
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'BranchEntity' so +entity is unable to disambiguate.
warning:  	 'BranchEntity' (0x600003508f20) from NSManagedObjectModel (0x60000212d4f0) claims 'BranchEntity'.
CoreData: warning:  	 'BranchEntity' (0x600003508f20) from NSManagedObjectModel (0x60000212d4f0) claims 'BranchEntity'.
warning:  	 'BranchEntity' (0x600003516aa0) from NSManagedObjectModel (0x60000215a4e0) claims 'BranchEntity'.
CoreData: warning:  	 'BranchEntity' (0x600003516aa0) from NSManagedObjectModel (0x60000215a4e0) claims 'BranchEntity'.
warning:  	 'BranchEntity' (0x600003505e40) from NSManagedObjectModel (0x60000215d0e0) claims 'BranchEntity'.
CoreData: warning:  	 'BranchEntity' (0x600003505e40) from NSManagedObjectModel (0x60000215d0e0) claims 'BranchEntity'.
error: +[BranchEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[BranchEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicEntity' so +entity is unable to disambiguate.
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicEntity' so +entity is unable to disambiguate.
warning:  	 'TopicEntity' (0x60000350a3c0) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicEntity'.
CoreData: warning:  	 'TopicEntity' (0x60000350a3c0) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicEntity'.
warning:  	 'TopicEntity' (0x600003506d60) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicEntity'.
CoreData: warning:  	 'TopicEntity' (0x600003506d60) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicEntity'.
warning:  	 'TopicEntity' (0x600003516c00) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicEntity'.
CoreData: warning:  	 'TopicEntity' (0x600003516c00) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicEntity'.
error: +[TopicEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[TopicEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicPageEntity' so +entity is unable to disambiguate.
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicPageEntity' so +entity is unable to disambiguate.
warning:  	 'TopicPageEntity' (0x60000350a470) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicPageEntity'.
CoreData: warning:  	 'TopicPageEntity' (0x60000350a470) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicPageEntity'.
warning:  	 'TopicPageEntity' (0x600003516cb0) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicPageEntity'.
CoreData: warning:  	 'TopicPageEntity' (0x600003516cb0) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicPageEntity'.
warning:  	 'TopicPageEntity' (0x600003506e10) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicPageEntity'.
CoreData: warning:  	 'TopicPageEntity' (0x600003506e10) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicPageEntity'.
error: +[TopicPageEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[TopicPageEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicPageAssetEntity' so +entity is unable to disambiguate.
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicPageAssetEntity' so +entity is unable to disambiguate.
warning:  	 'TopicPageAssetEntity' (0x60000350a520) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicPageAssetEntity'.
CoreData: warning:  	 'TopicPageAssetEntity' (0x60000350a520) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicPageAssetEntity'.
warning:  	 'TopicPageAssetEntity' (0x600003506ec0) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicPageAssetEntity'.
CoreData: warning:  	 'TopicPageAssetEntity' (0x600003506ec0) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicPageAssetEntity'.
warning:  	 'TopicPageAssetEntity' (0x600003516d60) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicPageAssetEntity'.
CoreData: warning:  	 'TopicPageAssetEntity' (0x600003516d60) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicPageAssetEntity'.
error: +[TopicPageAssetEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[TopicPageAssetEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicSectionEntity' so +entity is unable to disambiguate.
error: +[TopicSectionEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'TopicSectionEntity' so +entity is unable to disambiguate.
warning:  	 'TopicSectionEntity' (0x60000350a5d0) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicSectionEntity'.
CoreData: error: +[TopicSectionEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: warning:  	 'TopicSectionEntity' (0x60000350a5d0) from NSManagedObjectModel (0x60000212d4f0) claims 'TopicSectionEntity'.
warning:  	 'TopicSectionEntity' (0x600003516e10) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicSectionEntity'.
CoreData: warning:  	 'TopicSectionEntity' (0x600003516e10) from NSManagedObjectModel (0x60000215a4e0) claims 'TopicSectionEntity'.
warning:  	 'TopicSectionEntity' (0x600003506f70) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicSectionEntity'.
CoreData: warning:  	 'TopicSectionEntity' (0x600003506f70) from NSManagedObjectModel (0x60000215d0e0) claims 'TopicSectionEntity'.
error: +[TopicSectionEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
CoreData: error: +[TopicSectionEntity entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
βœ” Test initFromEntity_shouldPreserveData2() passed after 0.029 seconds.
βœ” Suite CoreDataManagerSwiftTests2 passed after 0.029 seconds.
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unacceptable type of value for to-one relationship: property = "topic"; desired type = TopicEntity; given type = TopicEntity; value = <TopicEntity: 0x600002157cf0> (entity: TopicEntity; id: 0x60000030ae20 <x-coredata:///TopicEntity/t48893A0D-1B6E-4D9D-AB30-0339755752687>; data: {
    iconId = nil;
    id = nil;
    name = nil;
    pages =     (
        "0x60000030aec0 <x-coredata:///TopicPageEntity/t48893A0D-1B6E-4D9D-AB30-0339755752688>",
        "0x6000003054a0 <x-coredata:///TopicPageEntity/t48893A0D-1B6E-4D9D-AB30-03397557526812>"
    );
    sections =     (
        "0x60000030e1c0 <x-coredata:///TopicSectionEntity/t48893A0D-1B6E-4D9D-AB30-03397557526819>"
    );
    showInAppNavigation = nil;
    updatedAt = nil;
    users =     (
    );
}).'

To me, it appears that both saveNewTopic_shouldSave() and initFromEntity_shouldPreserveData2() start at the same time and both attempt to register to something shared (perhaps NSPersistentStoreCoordinator?). If that is the case, then I don't quite know how to get around things. It's not a problem to use a single test suite, but I'm concerned that it's a bit of a gotcha for other team members who are adding tests in the future. Happy to change my implementation of CoreDataManager if we need to, as ideally we would prevent this from happening regardless of how the tests are written.

edit: Worth noting I was seeing these warnings in XCTests before this, but hadn't put any time into chasing them down and they were just warnings, not errors

Update for anyone also experiencing this. It appears that the default initializer for NSManagedObject uses some common store for entity descriptions, as per this StackOverflow answer.

Overriding this method to explicitly insert the entity into the correct context removes any errors and allows me to continue using my manager as defined above. In the end, with enough parallel XCTest classes I experienced the same issues–I suppose Swift Testing is better at parallelization.

extension NSManagedObject {
    // Override default init to ensure entities are inserted into the correct context:
    // https://stackoverflow.com/questions/51851485/multiple-nsentitydescriptions-claim-nsmanagedobject-subclass/
    convenience init(context: NSManagedObjectContext) {
        let name = String(describing: type(of: self))
        let entity = NSEntityDescription.entity(forEntityName: name, in: context)!
        self.init(entity: entity, insertInto: context)
    }
}

Thanks for your help with this @mbrandonw :ok_hand: