Chapter 13: Unit Testing the Core Data Stack
Understanding the Testing Challenge
- State Leakage: Test A creates an "Office Supplies" category. Test B expects 0 categories but fails because it reads Test A's category from the disk.
- Performance: Disk I/O operations (writing to and reading from SQLite) are relatively slow. A comprehensive test suite might have hundreds of database interactions. Relying on disk I/O will significantly drag down the speed of your test suite, violating the principle that unit tests should be lightning fast.
- Cleanup Overhead and WAL Files: You would need to aggressively delete the SQLite file before or after every single test. However, SQLite in Core Data uses Write-Ahead Logging (WAL) by default. This creates additional
-waland-shmfiles. If you only delete the.sqlitefile but miss the WAL files, you will encounter "ghost state" where deleted data reappears, leading to maddening debugging sessions.
Mocking vs. In-Memory Store: The Great Debate
Setting Up the In-Memory Persistent Store
import CoreData
class CoreDataStack {
static let shared = CoreDataStack()
let persistentContainer: NSPersistentContainer
var viewContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
/// Initializes the CoreDataStack.
/// - Parameter inMemory: If true, configures the stack to use an in-memory store. Crucial for unit testing.
init(inMemory: Bool = false) {
/*
When running tests, the test bundle is different from the main app bundle.
Sometimes `NSPersistentContainer(name:)` fails to find the `.momd` file if it's
relying on `Bundle.main` and the test target hasn't explicitly included the model.
To be absolutely safe, we locate the model in all bundles.
*/
guard let modelURL = Bundle.allBundles.compactMap({ $0.url(forResource: "ExpenseTracker", withExtension: "momd") }).first,
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Failed to locate Core Data model.")
}
persistentContainer = NSPersistentContainer(name: "ExpenseTracker", managedObjectModel: managedObjectModel)
if inMemory {
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
// Essential for testing: prevents creating a physical mapping file
description.url = URL(fileURLWithPath: "/dev/null")
description.shouldAddStoreAsynchronously = false // Tests should execute synchronously
persistentContainer.persistentStoreDescriptions = [description]
}
persistentContainer.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load Core Data stack: \(error)")
}
}
// Ensure the view context merges changes saved in background contexts
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
// Set merge policy to resolve conflicts in memory in favor of the store
persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
[!NOTE] Why
/dev/null? While specifyingNSInMemoryStoreTypetells Core Data not to write to a traditional SQLite file, setting the URL to/dev/nullprovides a critical extra layer of guarantee on UNIX-based systems (like macOS/iOS). Core Data and SQLite can sometimes attempt to create temporary mapping or swap files when memory usage gets high. Directing this to/dev/nullensures absolutely no physical bytes are ever written to disk. [!TIP] Notice we also setdescription.shouldAddStoreAsynchronously = false. In production, you might load stores asynchronously to keep the main thread unblocked on launch. In unit tests, we want our setup to be strictly synchronous so the test doesn't proceed until the database is fully ready.
Designing the Test Suite Base Class
import XCTest
import CoreData
@testable import ExpenseTracker
class CoreDataTestCase: XCTestCase {
var coreDataStack: CoreDataStack!
var context: NSManagedObjectContext!
override func setUpWithError() throws {
try super.setUpWithError()
// Initialize an isolated, in-memory stack for EVERY test
coreDataStack = CoreDataStack(inMemory: true)
context = coreDataStack.viewContext
}
override func tearDownWithError() throws {
// 1. Reset the context to aggressively break retain cycles on managed objects
context.reset()
// 2. Deallocate the stack and context to ensure clean state and prevent memory leaks
context = nil
coreDataStack = nil
try super.tearDownWithError()
}
}
The Importance of Object Lifecycle in Testing
Testing the MVVM Data Layer: The Repository
import Foundation
import CoreData
protocol ExpenseRepositoryProtocol {
func fetchAllExpenses() throws -> [Expense]
func addExpense(amount: Double, title: String, date: Date, category: Category?) throws
func delete(expense: Expense) throws
}
class ExpenseRepository: ExpenseRepositoryProtocol {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func fetchAllExpenses() throws -> [Expense] {
let request: NSFetchRequest = Expense.fetchRequest()
// Primary sort: newest first
request.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
return try context.fetch(request)
}
func addExpense(amount: Double, title: String, date: Date, category: Category?) throws {
let expense = Expense(context: context)
expense.id = UUID()
expense.amount = amount
expense.title = title
expense.date = date
expense.category = category
if context.hasChanges {
try context.save()
}
}
func delete(expense: Expense) throws {
context.delete(expense)
if context.hasChanges {
try context.save()
}
}
}
Writing the First Test: Saving and Fetching
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
// Inject the isolated test context into the repository
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
// Given (Arrange)
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
// When (Act)
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
// Then (Assert)
// We assert against the context directly to prove the repository actually persisted the data
let request: NSFetchRequest<Expense> = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1, "There should be exactly one expense in the store.")
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
}
Testing Validation Errors (Edge Cases)
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
let request: NSFetchRequest = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1)
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
func testAddExpense_WithEmptyTitle_ThrowsValidationError() {
let emptyTitle = ""
let amount = 10.0
XCTAssertThrowsError(try repository.addExpense(amount: amount, title: emptyTitle, date: Date(), category: nil)) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertTrue(nsError.code == NSValidationStringTooShortError || nsError.code == NSValidationErrorMinimum)
}
}
}
Testing Sorting and Order
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
let request: NSFetchRequest = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1)
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
func testAddExpense_WithEmptyTitle_ThrowsValidationError() {
let emptyTitle = ""
let amount = 10.0
XCTAssertThrowsError(try repository.addExpense(amount: amount, title: emptyTitle, date: Date(), category: nil)) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertTrue(nsError.code == NSValidationStringTooShortError || nsError.code == NSValidationErrorMinimum)
}
}
func testFetchAllExpenses_ReturnsExpensesSortedByDateDescending() throws {
let today = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
try repository.addExpense(amount: 10, title: "Today", date: today, category: nil)
try repository.addExpense(amount: 20, title: "Yesterday", date: yesterday, category: nil)
try repository.addExpense(amount: 30, title: "Tomorrow", date: tomorrow, category: nil)
let fetchedExpenses = try repository.fetchAllExpenses()
XCTAssertEqual(fetchedExpenses.count, 3)
XCTAssertEqual(fetchedExpenses[0].title, "Tomorrow")
XCTAssertEqual(fetchedExpenses[1].title, "Today")
XCTAssertEqual(fetchedExpenses[2].title, "Yesterday")
}
}
Testing Relationships: The Real Power of Core Data
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
let request: NSFetchRequest = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1)
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
func testAddExpense_WithEmptyTitle_ThrowsValidationError() {
let emptyTitle = ""
let amount = 10.0
XCTAssertThrowsError(try repository.addExpense(amount: amount, title: emptyTitle, date: Date(), category: nil)) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertTrue(nsError.code == NSValidationStringTooShortError || nsError.code == NSValidationErrorMinimum)
}
}
func testFetchAllExpenses_ReturnsExpensesSortedByDateDescending() throws {
let today = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
try repository.addExpense(amount: 10, title: "Today", date: today, category: nil)
try repository.addExpense(amount: 20, title: "Yesterday", date: yesterday, category: nil)
try repository.addExpense(amount: 30, title: "Tomorrow", date: tomorrow, category: nil)
let fetchedExpenses = try repository.fetchAllExpenses()
XCTAssertEqual(fetchedExpenses.count, 3)
XCTAssertEqual(fetchedExpenses[0].title, "Tomorrow")
XCTAssertEqual(fetchedExpenses[1].title, "Today")
XCTAssertEqual(fetchedExpenses[2].title, "Yesterday")
}
private func createTestCategory(name: String) -> Category {
let category = Category(context: context)
category.id = UUID()
category.name = name
category.colorHex = "#FF0000"
return category
}
func testAddExpense_WithCategory_EstablishesInverseRelationship() throws {
let groceriesCategory = createTestCategory(name: "Groceries")
try repository.addExpense(amount: 150.0, title: "Whole Foods", date: Date(), category: groceriesCategory)
let fetchedExpenses = try repository.fetchAllExpenses()
let savedExpense = try XCTUnwrap(fetchedExpenses.first)
XCTAssertEqual(savedExpense.category?.name, "Groceries")
XCTAssertEqual(groceriesCategory.expenses?.count, 1)
let categoryExpenses = groceriesCategory.expenses as? Set
XCTAssertTrue(categoryExpenses?.contains(savedExpense) ?? false)
}
}
[!TIP] Notice the use of
XCTUnwrap. This is a powerful assertion introduced in XCTest. It takes an optional value, asserts that it is notnil, and unwraps it. If it isnil, the test immediately fails with a clear message. This saves you from writing nestedif letblocks or forcibly unwrapping with!(which causes ugly test runner crashes that obscure the actual failure point).
Testing Deletion Rules: Cascade vs. Deny vs. Nullify
- Nullify: If a category is deleted, the expense's
categoryproperty becomesnil. (The expense is preserved). - Cascade: If a category is deleted, all associated expenses are also deleted automatically.
- Deny: If a category has associated expenses, Core Data refuses to delete the category and throws an error on save.
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
let request: NSFetchRequest = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1)
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
func testAddExpense_WithEmptyTitle_ThrowsValidationError() {
let emptyTitle = ""
let amount = 10.0
XCTAssertThrowsError(try repository.addExpense(amount: amount, title: emptyTitle, date: Date(), category: nil)) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertTrue(nsError.code == NSValidationStringTooShortError || nsError.code == NSValidationErrorMinimum)
}
}
func testFetchAllExpenses_ReturnsExpensesSortedByDateDescending() throws {
let today = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
try repository.addExpense(amount: 10, title: "Today", date: today, category: nil)
try repository.addExpense(amount: 20, title: "Yesterday", date: yesterday, category: nil)
try repository.addExpense(amount: 30, title: "Tomorrow", date: tomorrow, category: nil)
let fetchedExpenses = try repository.fetchAllExpenses()
XCTAssertEqual(fetchedExpenses.count, 3)
XCTAssertEqual(fetchedExpenses[0].title, "Tomorrow")
XCTAssertEqual(fetchedExpenses[1].title, "Today")
XCTAssertEqual(fetchedExpenses[2].title, "Yesterday")
}
private func createTestCategory(name: String) -> Category {
let category = Category(context: context)
category.id = UUID()
category.name = name
category.colorHex = "#FF0000"
return category
}
func testAddExpense_WithCategory_EstablishesInverseRelationship() throws {
let groceriesCategory = createTestCategory(name: "Groceries")
try repository.addExpense(amount: 150.0, title: "Whole Foods", date: Date(), category: groceriesCategory)
let fetchedExpenses = try repository.fetchAllExpenses()
let savedExpense = try XCTUnwrap(fetchedExpenses.first)
XCTAssertEqual(savedExpense.category?.name, "Groceries")
XCTAssertEqual(groceriesCategory.expenses?.count, 1)
let categoryExpenses = groceriesCategory.expenses as? Set
XCTAssertTrue(categoryExpenses?.contains(savedExpense) ?? false)
}
func testDeleteCategory_WithAssociatedExpenses_ThrowsDenyError() throws {
let category = createTestCategory(name: "Utilities")
try repository.addExpense(amount: 100, title: "Electric Bill", date: Date(), category: category)
context.delete(category)
XCTAssertThrowsError(try context.save(), "Saving the context should fail because of the Deny deletion rule.") { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertEqual(nsError.code, NSValidationRelationshipDeniedError)
}
}
}
Testing NSFetchedResultsController Logic
import CoreData
import Observation
@Observable class MonthlyExpenseViewModel: NSObject, NSFetchedResultsControllerDelegate {
var sections: [NSFetchedResultsSectionInfo] = []
private let context: NSManagedObjectContext
private var fetchedResultsController: NSFetchedResultsController<Expense>!
init(context: NSManagedObjectContext) {
self.context = context
super.init()
setupFRC()
}
private func setupFRC() {
let request: NSFetchRequest<Expense> = Expense.fetchRequest()
// Primary sort for sections (Month/Year), secondary sort for rows (Date)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Expense.sectionIdentifier, ascending: false),
NSSortDescriptor(keyPath: \Expense.date, ascending: false)
]
// In testing with NSInMemoryStoreType, fetchBatchSize is ignored because
// the store doesn't support faulting from disk, but it's safe to declare here.
request.fetchBatchSize = 20
fetchedResultsController = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: "sectionIdentifier", // e.g., "2023-10"
cacheName: nil
)
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
sections = fetchedResultsController.sections ?? []
} catch {
print("Fetch failed")
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.sections = controller.sections ?? []
}
}
final class MonthlyExpenseViewModelTests: CoreDataTestCase {
var viewModel: MonthlyExpenseViewModel!
override func setUpWithError() throws {
try super.setUpWithError()
viewModel = MonthlyExpenseViewModel(context: context)
}
override func tearDownWithError() throws {
viewModel = nil
try super.tearDownWithError()
}
func testViewModelSections_UpdatesAutomaticallyWhenExpensesAreAdded() throws {
// Given
XCTAssertEqual(viewModel.sections.count, 0, "Initially there should be no sections")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let octDate = formatter.date(from: "2023-10-15")!
let novDate = formatter.date(from: "2023-11-05")!
// When - Add expenses across two different months
let expense1 = Expense(context: context)
expense1.title = "October Coffee"
expense1.date = octDate
expense1.sectionIdentifier = "2023-10"
let expense2 = Expense(context: context)
expense2.title = "November Rent"
expense2.date = novDate
expense2.sectionIdentifier = "2023-11"
// Crucial: The NSFRC only reacts when the context is actually saved!
try context.save()
// Then - The FRC delegate should have fired and updated the observable sections
XCTAssertEqual(viewModel.sections.count, 2, "There should be two sections representing the two months")
// Sections are sorted descending by sectionIdentifier, so "2023-11" comes first
let novSection = viewModel.sections[0]
XCTAssertEqual(novSection.name, "2023-11")
XCTAssertEqual(novSection.numberOfObjects, 1)
let octSection = viewModel.sections[1]
XCTAssertEqual(octSection.name, "2023-10")
XCTAssertEqual(octSection.numberOfObjects, 1)
}
}
[!WARNING] Faulting in In-Memory Stores: Note that an in-memory store keeps all objects fully realized in memory. Features like
fetchBatchSizeor testing object faulting (isFault) will not behave the same way they do with an SQLite store. Do not write tests that explicitly assert whether an object is a fault when usingNSInMemoryStoreType.
Advanced Testing: Concurrency and Background Contexts
import Foundation
import CoreData
protocol ExpenseRepositoryProtocol {
func fetchAllExpenses() throws -> [Expense]
func addExpense(amount: Double, title: String, date: Date, category: Category?) throws
func delete(expense: Expense) throws
func importExpenses(payloads: [[String: Any]], completion: @escaping (Result) -> Void)
}
class ExpenseRepository: ExpenseRepositoryProtocol {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func fetchAllExpenses() throws -> [Expense] {
let request: NSFetchRequest = Expense.fetchRequest()
// Primary sort: newest first
request.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
return try context.fetch(request)
}
func addExpense(amount: Double, title: String, date: Date, category: Category?) throws {
let expense = Expense(context: context)
expense.id = UUID()
expense.amount = amount
expense.title = title
expense.date = date
expense.category = category
if context.hasChanges {
try context.save()
}
}
func delete(expense: Expense) throws {
context.delete(expense)
if context.hasChanges {
try context.save()
}
}
func importExpenses(payloads: [[String: Any]], completion: @escaping (Result) -> Void) {
let backgroundContext = context.persistentStoreCoordinator?.newBackgroundContext() ?? NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.perform {
do {
for payload in payloads {
let expense = Expense(context: backgroundContext)
expense.title = payload["title"] as? String ?? ""
expense.amount = payload["amount"] as? Double ?? 0.0
expense.date = payload["date"] as? Date ?? Date()
}
try backgroundContext.save()
DispatchQueue.main.async {
completion(.success(()))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
}
Using XCTestExpectation
import XCTest
import CoreData
@testable import ExpenseTracker
final class ExpenseRepositoryTests: CoreDataTestCase {
var repository: ExpenseRepository!
override func setUpWithError() throws {
try super.setUpWithError()
repository = ExpenseRepository(context: self.context)
}
override func tearDownWithError() throws {
repository = nil
try super.tearDownWithError()
}
func testAddExpense_SuccessfullySavesToContext() throws {
let title = "Morning Coffee"
let amount = 4.50
let date = Date()
try repository.addExpense(amount: amount, title: title, date: date, category: nil)
let request: NSFetchRequest = Expense.fetchRequest()
let expenses = try context.fetch(request)
XCTAssertEqual(expenses.count, 1)
XCTAssertEqual(expenses.first?.title, "Morning Coffee")
XCTAssertEqual(expenses.first?.amount, 4.50)
}
func testAddExpense_WithEmptyTitle_ThrowsValidationError() {
let emptyTitle = ""
let amount = 10.0
XCTAssertThrowsError(try repository.addExpense(amount: amount, title: emptyTitle, date: Date(), category: nil)) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertTrue(nsError.code == NSValidationStringTooShortError || nsError.code == NSValidationErrorMinimum)
}
}
func testFetchAllExpenses_ReturnsExpensesSortedByDateDescending() throws {
let today = Date()
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
try repository.addExpense(amount: 10, title: "Today", date: today, category: nil)
try repository.addExpense(amount: 20, title: "Yesterday", date: yesterday, category: nil)
try repository.addExpense(amount: 30, title: "Tomorrow", date: tomorrow, category: nil)
let fetchedExpenses = try repository.fetchAllExpenses()
XCTAssertEqual(fetchedExpenses.count, 3)
XCTAssertEqual(fetchedExpenses[0].title, "Tomorrow")
XCTAssertEqual(fetchedExpenses[1].title, "Today")
XCTAssertEqual(fetchedExpenses[2].title, "Yesterday")
}
private func createTestCategory(name: String) -> Category {
let category = Category(context: context)
category.id = UUID()
category.name = name
category.colorHex = "#FF0000"
return category
}
func testAddExpense_WithCategory_EstablishesInverseRelationship() throws {
let groceriesCategory = createTestCategory(name: "Groceries")
try repository.addExpense(amount: 150.0, title: "Whole Foods", date: Date(), category: groceriesCategory)
let fetchedExpenses = try repository.fetchAllExpenses()
let savedExpense = try XCTUnwrap(fetchedExpenses.first)
XCTAssertEqual(savedExpense.category?.name, "Groceries")
XCTAssertEqual(groceriesCategory.expenses?.count, 1)
let categoryExpenses = groceriesCategory.expenses as? Set
XCTAssertTrue(categoryExpenses?.contains(savedExpense) ?? false)
}
func testDeleteCategory_WithAssociatedExpenses_ThrowsDenyError() throws {
let category = createTestCategory(name: "Utilities")
try repository.addExpense(amount: 100, title: "Electric Bill", date: Date(), category: category)
context.delete(category)
XCTAssertThrowsError(try context.save(), "Saving the context should fail because of the Deny deletion rule.") { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
XCTAssertEqual(nsError.code, NSValidationRelationshipDeniedError)
}
}
func testImportExpenses_SavesToBackgroundAndMergesToMainContext() throws {
let payloads: [[String: Any]] = [
["title": "Server Cost", "amount": 120.0, "date": Date()],
["title": "Domain Renewal", "amount": 15.0, "date": Date()]
]
let expectation = XCTestExpectation(description: "Background import completes")
repository.importExpenses(payloads: payloads) { result in
switch result {
case .success:
expectation.fulfill()
case .failure(let error):
XCTFail("Import failed with error: \(error)")
}
}
wait(for: [expectation], timeout: 2.0)
let fetchedExpenses = try repository.fetchAllExpenses()
XCTAssertEqual(fetchedExpenses.count, 2, "Main context should reflect the imported expenses")
}
}
Synchronous Testing with performAndWait
Best Practices and Pitfalls to Avoid
- Never use the production SQLite store for unit tests. Always use
NSInMemoryStoreType. This prevents disk clutter, WAL file ghosting, and cross-test contamination. - Setup and Teardown are sacred. Ensure your
coreDataStackandcontextare strictly re-instantiated insetUpand set tonilintearDown. Callcontext.reset()to drop all objects before nil-ing out the context. - Assert against the Context, not just the Repository. When you test a
save()operation, write a manual fetch request in theThenblock of your test to query the context directly. This proves the data actually entered Core Data, rather than relying on an assumption that a local in-memory array variable was updated. - Test the relationships and rules. Relationships, inverse updates, and cascade/deny rules are where Core Data shines and where logic bugs easily hide.
- Beware Memory Limits. The in-memory store keeps everything in RAM. If you write a performance test that generates 100,000 entities, you may trigger JetSam (iOS memory killer) or exhaust test runner memory. Keep mock datasets small but representative.