Files
gh-glittercowboy-taches-cc-…/skills/expertise/macos-apps/references/testing-debugging.md
2025-11-29 18:28:37 +08:00

14 KiB

Testing and Debugging

Patterns for unit testing, UI testing, and debugging macOS apps.

<unit_testing> <basic_test>

import XCTest
@testable import MyApp

final class DataServiceTests: XCTestCase {
    var sut: DataService!

    override func setUp() {
        super.setUp()
        sut = DataService()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testAddItem() {
        // Given
        let item = Item(name: "Test")

        // When
        sut.addItem(item)

        // Then
        XCTAssertEqual(sut.items.count, 1)
        XCTAssertEqual(sut.items.first?.name, "Test")
    }

    func testDeleteItem() {
        // Given
        let item = Item(name: "Test")
        sut.addItem(item)

        // When
        sut.deleteItem(item.id)

        // Then
        XCTAssertTrue(sut.items.isEmpty)
    }
}

</basic_test>

<async_testing>

final class NetworkServiceTests: XCTestCase {
    var sut: NetworkService!
    var mockSession: MockURLSession!

    override func setUp() {
        super.setUp()
        mockSession = MockURLSession()
        sut = NetworkService(session: mockSession)
    }

    func testFetchProjects() async throws {
        // Given
        let expectedProjects = [Project(name: "Test")]
        mockSession.data = try JSONEncoder().encode(expectedProjects)
        mockSession.response = HTTPURLResponse(
            url: URL(string: "https://api.example.com")!,
            statusCode: 200,
            httpVersion: nil,
            headerFields: nil
        )

        // When
        let projects: [Project] = try await sut.fetch(Endpoint.projects().request)

        // Then
        XCTAssertEqual(projects.count, 1)
        XCTAssertEqual(projects.first?.name, "Test")
    }

    func testFetchError() async {
        // Given
        mockSession.error = NetworkError.timeout

        // When/Then
        do {
            let _: [Project] = try await sut.fetch(Endpoint.projects().request)
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is NetworkError)
        }
    }
}

</async_testing>

<testing_observables>

final class AppStateTests: XCTestCase {
    func testAddItem() {
        // Given
        let sut = AppState()

        // When
        sut.addItem(Item(name: "Test"))

        // Then
        XCTAssertEqual(sut.items.count, 1)
    }

    func testSelectedItem() {
        // Given
        let sut = AppState()
        let item = Item(name: "Test")
        sut.items = [item]

        // When
        sut.selectedItemID = item.id

        // Then
        XCTAssertEqual(sut.selectedItem?.name, "Test")
    }
}

</testing_observables>

<mock_dependencies>

// Protocol for testability
protocol DataStoreProtocol {
    func fetchAll() async throws -> [Item]
    func save(_ item: Item) async throws
}

// Mock implementation
class MockDataStore: DataStoreProtocol {
    var itemsToReturn: [Item] = []
    var savedItems: [Item] = []
    var shouldThrow = false

    func fetchAll() async throws -> [Item] {
        if shouldThrow { throw TestError.mock }
        return itemsToReturn
    }

    func save(_ item: Item) async throws {
        if shouldThrow { throw TestError.mock }
        savedItems.append(item)
    }
}

enum TestError: Error {
    case mock
}

// Test using mock
final class ViewModelTests: XCTestCase {
    func testLoadItems() async throws {
        // Given
        let mockStore = MockDataStore()
        mockStore.itemsToReturn = [Item(name: "Test")]
        let sut = ViewModel(dataStore: mockStore)

        // When
        await sut.loadItems()

        // Then
        XCTAssertEqual(sut.items.count, 1)
    }
}

</mock_dependencies>

<testing_swiftdata>

final class SwiftDataTests: XCTestCase {
    var container: ModelContainer!
    var context: ModelContext!

    override func setUp() {
        super.setUp()

        let schema = Schema([Project.self, Task.self])
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try! ModelContainer(for: schema, configurations: config)
        context = ModelContext(container)
    }

    func testCreateProject() throws {
        // Given
        let project = Project(name: "Test")

        // When
        context.insert(project)
        try context.save()

        // Then
        let descriptor = FetchDescriptor<Project>()
        let projects = try context.fetch(descriptor)
        XCTAssertEqual(projects.count, 1)
        XCTAssertEqual(projects.first?.name, "Test")
    }

    func testCascadeDelete() throws {
        // Given
        let project = Project(name: "Test")
        let task = Task(title: "Task")
        task.project = project
        context.insert(project)
        context.insert(task)
        try context.save()

        // When
        context.delete(project)
        try context.save()

        // Then
        let tasks = try context.fetch(FetchDescriptor<Task>())
        XCTAssertTrue(tasks.isEmpty)
    }
}

</testing_swiftdata> </unit_testing>

<swiftdata_debugging> <verify_relationships> When SwiftData items aren't appearing or relationships seem broken:

// Debug print to verify relationships
func debugRelationships(for column: Column) {
    print("=== Column: \(column.name) ===")
    print("Cards count: \(column.cards.count)")
    for card in column.cards {
        print("  - Card: \(card.title)")
        print("    Card's column: \(card.column?.name ?? "NIL")")
    }
}

// Verify inverse relationships are set
func verifyCard(_ card: Card) {
    if card.column == nil {
        print("⚠️ Card '\(card.title)' has no column set!")
    } else {
        let inParentArray = card.column!.cards.contains { $0.id == card.id }
        print("Card in column.cards: \(inParentArray)")
    }
}

</verify_relationships>

<common_swiftdata_issues> Issue: Items not appearing in list

Symptoms: Added items don't show, count is 0

Debug steps:

// 1. Check modelContext has the item
let descriptor = FetchDescriptor<Card>()
let allCards = try? modelContext.fetch(descriptor)
print("Total cards in context: \(allCards?.count ?? 0)")

// 2. Check relationship is set
if let card = allCards?.first {
    print("Card column: \(card.column?.name ?? "NIL")")
}

// 3. Check parent's array
print("Column.cards count: \(column.cards.count)")

Common causes:

  • Forgot modelContext.insert(item) for new objects
  • Didn't set inverse relationship (card.column = column)
  • Using wrong modelContext (view context vs background context) </common_swiftdata_issues>

<inspect_database>

// Print database location
func printDatabaseLocation() {
    let url = URL.applicationSupportDirectory
        .appendingPathComponent("default.store")
    print("Database: \(url.path)")
}

// Dump all items of a type
func dumpAllItems<T: PersistentModel>(_ type: T.Type, context: ModelContext) {
    let descriptor = FetchDescriptor<T>()
    if let items = try? context.fetch(descriptor) {
        print("=== \(String(describing: T.self)) (\(items.count)) ===")
        for item in items {
            print("  \(item)")
        }
    }
}

// Usage
dumpAllItems(Column.self, context: modelContext)
dumpAllItems(Card.self, context: modelContext)

</inspect_database>

<logging_swiftdata_operations>

import os

let dataLogger = Logger(subsystem: "com.yourapp", category: "SwiftData")

// Log when adding items
func addCard(to column: Column, title: String) {
    let card = Card(title: title, position: 1.0)
    card.column = column
    modelContext.insert(card)

    dataLogger.debug("Added card '\(title)' to column '\(column.name)'")
    dataLogger.debug("Column now has \(column.cards.count) cards")
}

// Log when relationships change
func moveCard(_ card: Card, to newColumn: Column) {
    let oldColumn = card.column?.name ?? "none"
    card.column = newColumn

    dataLogger.debug("Moved '\(card.title)' from '\(oldColumn)' to '\(newColumn.name)'")
}

// View logs in Console.app or:
// log stream --predicate 'subsystem == "com.yourapp" AND category == "SwiftData"' --level debug

</logging_swiftdata_operations>

<symptom_cause_table> Quick reference for common SwiftData symptoms:

Symptom Likely Cause Fix
Items don't appear Missing insert() Call modelContext.insert(item)
Items appear once then disappear Inverse relationship not set Set child.parent = parent before insert
Changes don't persist Wrong context Use same modelContext throughout
@Query returns empty Schema mismatch Verify @Model matches container schema
Cascade delete fails Missing deleteRule Add @Relationship(deleteRule: .cascade)
Relationship array always empty Not using inverse Set inverse on child, not append on parent
</symptom_cause_table>
</swiftdata_debugging>

<ui_testing>

import XCTest

final class MyAppUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testAddItem() {
        // Tap add button
        app.buttons["Add"].click()

        // Verify item appears in list
        XCTAssertTrue(app.staticTexts["New Item"].exists)
    }

    func testRenameItem() {
        // Add item first
        app.buttons["Add"].click()

        // Select and rename
        app.staticTexts["New Item"].click()
        let textField = app.textFields["Name"]
        textField.click()
        textField.typeText("Renamed Item")

        // Verify
        XCTAssertTrue(app.staticTexts["Renamed Item"].exists)
    }

    func testDeleteItem() {
        // Add item
        app.buttons["Add"].click()

        // Right-click and delete
        app.staticTexts["New Item"].rightClick()
        app.menuItems["Delete"].click()

        // Verify deleted
        XCTAssertFalse(app.staticTexts["New Item"].exists)
    }
}

</ui_testing>

```swift import os

let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "General")

// Log levels logger.debug("Debug info") logger.info("General info") logger.notice("Notable event") logger.error("Error occurred") logger.fault("Critical failure")

// With interpolation logger.info("Loaded (items.count) items")

// Privacy for sensitive data logger.info("User: (username, privacy: .private)")

// In console // log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug

</os_log>

<signposts>
```swift
import os

let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")

func loadData() async {
    let signpostID = signposter.makeSignpostID()
    let state = signposter.beginInterval("Load Data", id: signpostID)

    // Work
    await fetchFromNetwork()

    signposter.endInterval("Load Data", state)
}

// Interval with metadata
func processItem(_ item: Item) {
    let state = signposter.beginInterval("Process Item", id: signposter.makeSignpostID())

    // Work
    process(item)

    signposter.endInterval("Process Item", state, "Processed \(item.name)")
}

<breakpoint_actions>

// Symbolic breakpoints in Xcode:
// - Symbol: `-[NSException raise]` to catch all exceptions
// - Symbol: `UIViewAlertForUnsatisfiableConstraints` for layout issues

// In code, trigger debugger
func criticalFunction() {
    guard condition else {
        #if DEBUG
        raise(SIGINT)  // Triggers breakpoint
        #endif
        return
    }
}

</breakpoint_actions>

<memory_debugging>

// Check for leaks with weak references
class DebugHelper {
    static func trackDeallocation<T: AnyObject>(_ object: T, name: String) {
        let observer = DeallocObserver(name: name)
        objc_setAssociatedObject(object, "deallocObserver", observer, .OBJC_ASSOCIATION_RETAIN)
    }
}

class DeallocObserver {
    let name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        print("✓ \(name) deallocated")
    }
}

// Usage in tests
func testNoMemoryLeak() {
    weak var weakRef: ViewModel?

    autoreleasepool {
        let vm = ViewModel()
        weakRef = vm
        DebugHelper.trackDeallocation(vm, name: "ViewModel")
    }

    XCTAssertNil(weakRef, "ViewModel should be deallocated")
}

</memory_debugging>

<common_issues> <memory_leaks> Symptom: Memory grows over time, objects not deallocated

Common causes:

  • Strong reference cycles in closures
  • Delegate not weak
  • NotificationCenter observers not removed

Fix:

// Use [weak self]
someService.fetch { [weak self] result in
    self?.handle(result)
}

// Weak delegates
weak var delegate: MyDelegate?

// Remove observers
deinit {
    NotificationCenter.default.removeObserver(self)
}

</memory_leaks>

<main_thread_violations> Symptom: Purple warnings, UI not updating, crashes

Fix:

// Ensure UI updates on main thread
Task { @MainActor in
    self.items = fetchedItems
}

// Or use DispatchQueue
DispatchQueue.main.async {
    self.tableView.reloadData()
}

</main_thread_violations>

<swiftui_not_updating> Symptom: View doesn't reflect state changes

Common causes:

  • Missing @Observable
  • Property not being tracked
  • Binding not connected

Fix:

// Ensure class is @Observable
@Observable
class AppState {
    var items: [Item] = []  // This will be tracked
}

// Use @Bindable for mutations
@Bindable var appState = appState
TextField("Name", text: $appState.name)

</swiftui_not_updating> </common_issues>

<test_coverage>

# Build with coverage
xcodebuild -project MyApp.xcodeproj \
    -scheme MyApp \
    -enableCodeCoverage YES \
    -derivedDataPath ./build \
    test

# View coverage report
xcrun xccov view --report ./build/Logs/Test/*.xcresult

</test_coverage>

<performance_testing>

func testPerformanceLoadLargeDataset() {
    measure {
        let items = (0..<10000).map { Item(name: "Item \($0)") }
        sut.items = items
    }
}

// With options
func testPerformanceWithMetrics() {
    let metrics: [XCTMetric] = [
        XCTClockMetric(),
        XCTMemoryMetric(),
        XCTCPUMetric()
    ]

    measure(metrics: metrics) {
        performHeavyOperation()
    }
}

</performance_testing>