Initial commit
This commit is contained in:
612
skills/expertise/macos-apps/references/testing-debugging.md
Normal file
612
skills/expertise/macos-apps/references/testing-debugging.md
Normal file
@@ -0,0 +1,612 @@
|
||||
# Testing and Debugging
|
||||
|
||||
Patterns for unit testing, UI testing, and debugging macOS apps.
|
||||
|
||||
<unit_testing>
|
||||
<basic_test>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
// 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>
|
||||
```swift
|
||||
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:
|
||||
|
||||
```swift
|
||||
// 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:
|
||||
```swift
|
||||
// 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>
|
||||
```swift
|
||||
// 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>
|
||||
```swift
|
||||
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>
|
||||
```swift
|
||||
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>
|
||||
|
||||
<debugging>
|
||||
<os_log>
|
||||
```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)")
|
||||
}
|
||||
```
|
||||
</signposts>
|
||||
|
||||
<breakpoint_actions>
|
||||
```swift
|
||||
// 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>
|
||||
```swift
|
||||
// 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>
|
||||
</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**:
|
||||
```swift
|
||||
// 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**:
|
||||
```swift
|
||||
// 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**:
|
||||
```swift
|
||||
// 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>
|
||||
```bash
|
||||
# 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>
|
||||
```swift
|
||||
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>
|
||||
Reference in New Issue
Block a user