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)
}
}