-
Notifications
You must be signed in to change notification settings - Fork 0
Testing Guide
Purus Health uses the Swift Testing framework (not XCTest) for unit and integration tests. This guide covers testing patterns, best practices, and how to write effective tests for the app.
The app uses Swift Testing introduced in Swift 5.9+:
import Testing
@testable import PurusHealth
struct MedicalRecordTests {
@Test
func testRecordCreation() async throws {
// Test code
}
}Key Differences from XCTest:
- Use
@Testattribute instead oftestprefix - Use
#expect()instead ofXCTAssert() - Tests can be in structs instead of classes
- Better async/await support
- More descriptive test names
PurusHealthTests/
├── ModelTests/
│ ├── MedicalRecordTests.swift
│ ├── BloodEntryTests.swift
│ └── ...
├── ServiceTests/
│ ├── CloudSyncServiceTests.swift
│ ├── ExportServiceTests.swift
│ └── ...
└── ViewTests/
└── ... (if needed)
Use descriptive test names that explain what's being tested:
@Test
func testMedicalRecordDisplayNameForHuman() async throws {
// ...
}
@Test
func testMedicalRecordDisplayNameForPet() async throws {
// ...
}
@Test
func testBloodEntryCascadeDelete() async throws {
// ...
}Always use in-memory storage for tests:
@Test
func testModelPersistence() async throws {
// Create in-memory model container
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: MedicalRecord.self,
configurations: config
)
let context = container.mainContext
// Test code using context
}@Test
func testMedicalRecordCreation() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
let record = MedicalRecord()
record.personalGivenName = "John"
record.personalFamilyName = "Doe"
context.insert(record)
try context.save()
#expect(record.personalGivenName == "John")
#expect(record.personalFamilyName == "Doe")
#expect(record.displayName == "John Doe")
}@Test
func testBloodEntryRelationship() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
let record = MedicalRecord()
context.insert(record)
let bloodEntry = BloodEntry(date: Date(), value: "120/80", comment: "Normal")
record.blood.append(bloodEntry)
try context.save()
#expect(record.blood.count == 1)
#expect(record.blood.first === bloodEntry)
#expect(bloodEntry.record === record)
}@Test
func testCascadeDelete() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
let record = MedicalRecord()
context.insert(record)
let bloodEntry = BloodEntry(date: Date(), value: "120/80", comment: "Normal")
record.blood.append(bloodEntry)
try context.save()
// Delete parent record
context.delete(record)
try context.save()
// Verify entry was cascade deleted
let allBlood = try context.fetch(FetchDescriptor<BloodEntry>())
#expect(allBlood.isEmpty)
}@Test
func testDataPersistence() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
// First container
let container1 = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context1 = container1.mainContext
let record = MedicalRecord()
record.uuid = "test-uuid"
record.personalGivenName = "Jane"
context1.insert(record)
try context1.save()
// Second container (simulates app restart with in-memory)
// Note: In-memory doesn't persist, so this tests the model structure
let container2 = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context2 = container2.mainContext
// With in-memory, data won't persist
// For real persistence testing, use a temporary file URL
}@Test
func testPetRecord() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
let petRecord = MedicalRecord()
petRecord.isPet = true
petRecord.personalName = "Fluffy"
petRecord.petBreed = "Persian Cat"
context.insert(petRecord)
try context.save()
#expect(petRecord.isPet == true)
#expect(petRecord.displayName == "Fluffy")
#expect(petRecord.petBreed == "Persian Cat")
}
@Test
func testHumanRecord() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
let humanRecord = MedicalRecord()
humanRecord.isPet = false
humanRecord.personalGivenName = "John"
humanRecord.personalFamilyName = "Doe"
context.insert(humanRecord)
try context.save()
#expect(humanRecord.isPet == false)
#expect(humanRecord.displayName == "John Doe")
}@MainActor
struct CloudSyncServiceTests {
@Test
func testSyncSkipsNonCloudEnabledRecords() async throws {
let record = MedicalRecord()
record.isCloudEnabled = false
// Should not throw, should just skip
try await CloudSyncService.shared.syncIfNeeded(record: record)
// Verify no CloudKit operations occurred
#expect(record.cloudRecordName == nil)
}
}@MainActor
struct ExportServiceTests {
@Test
func testJSONExport() async throws {
let record = MedicalRecord()
record.personalGivenName = "John"
record.personalFamilyName = "Doe"
let jsonData = try ExportService.shared.exportRecordToJSON(record)
#expect(!jsonData.isEmpty)
// Verify JSON structure
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
#expect(json?["personalGivenName"] as? String == "John")
#expect(json?["personalFamilyName"] as? String == "Doe")
}
}struct HTMLTemplateRendererTests {
@Test
func testHTMLGeneration() async throws {
let record = MedicalRecord()
record.personalGivenName = "John"
record.personalFamilyName = "Doe"
let html = try HTMLTemplateRenderer.shared.render(record: record)
#expect(html.contains("<!DOCTYPE html>"))
#expect(html.contains("John Doe"))
#expect(html.contains("<html>"))
#expect(html.contains("</html>"))
}
}Always use in-memory storage for tests to ensure isolation:
let config = ModelConfiguration(isStoredInMemoryOnly: true)Delete test records after tests complete:
@Test
func testExample() async throws {
let context = // ... create context
let record = MedicalRecord()
context.insert(record)
try context.save()
// Test operations
// Clean up
context.delete(record)
try context.save()
}Always test edge cases and boundary conditions:
@Test
func testDisplayNameWithEmptyFields() async throws {
let record = MedicalRecord()
record.personalGivenName = ""
record.personalFamilyName = ""
#expect(record.displayName == "Unnamed Person")
}
@Test
func testDisplayNameWithOnlyGivenName() async throws {
let record = MedicalRecord()
record.personalGivenName = "John"
record.personalFamilyName = ""
#expect(record.displayName == "John")
}Mark tests that interact with SwiftData contexts as @MainActor:
@MainActor
struct ModelTests {
@Test
func testModelOperation() async throws {
let context = // ... ModelContext
// Test code
}
}For compatibility, fetch all records and filter in-memory rather than using predicate macros:
// ✅ Good - Compatible approach
let allRecords = try context.fetch(FetchDescriptor<MedicalRecord>())
let cloudEnabled = allRecords.filter { $0.isCloudEnabled }
// ❌ Avoid in tests - May have compatibility issues
let descriptor = FetchDescriptor<MedicalRecord>(
predicate: #Predicate { $0.isCloudEnabled == true }
)Use async throws for async tests:
@Test
func testAsyncOperation() async throws {
let result = await someAsyncFunction()
#expect(result != nil)
}#expect(value == expected)
#expect(value != unexpected)
#expect(value > 0)
#expect(value < 100)
#expect(array.count == 5)
#expect(!array.isEmpty)#expect(optionalValue != nil)
#expect(optionalValue?.property == "expected")
// Unwrap and test
if let value = optionalValue {
#expect(value.property == "expected")
}#expect(array.count == 3)
#expect(array.isEmpty)
#expect(array.contains(item))
#expect(array.first == expectedFirst)
#expect(array.last == expectedLast)#expect(condition)
#expect(!condition)
#expect(record.isPet == true)
#expect(record.isCloudEnabled == false)- Open Test Navigator (⌘6)
- Click play button next to test or test suite
- Or press ⌘U to run all tests
# Run all tests
xcodebuild test -scheme PurusHealth -destination 'platform=iOS Simulator,name=iPhone 15'
# Run specific test
xcodebuild test -scheme PurusHealth -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:PurusHealthTests/MedicalRecordTestsTests should run on CI for every commit:
# Example GitHub Actions workflow
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Run tests
run: |
xcodebuild test \
-scheme PurusHealth \
-destination 'platform=iOS Simulator,name=iPhone 15'- In Xcode, enable code coverage:
- Product → Scheme → Edit Scheme
- Test → Options → Code Coverage ✓
- Run tests (⌘U)
- View coverage in Report Navigator (⌘9)
Aim for high coverage on:
- Models: 90%+ (core data structures)
- Services: 80%+ (business logic)
- Views: 50%+ (UI logic is harder to test)
For CloudKit tests, use mock containers:
protocol CloudKitContainerProtocol {
func save(_ record: CKRecord) async throws -> CKRecord
}
class MockCloudKitContainer: CloudKitContainerProtocol {
var savedRecords: [CKRecord] = []
func save(_ record: CKRecord) async throws -> CKRecord {
savedRecords.append(record)
return record
}
}Create stub implementations for testing:
class StubExportService: ExportServiceProtocol {
var exportCalled = false
var returnData: Data?
func exportRecordToJSON(_ record: MedicalRecord) throws -> Data {
exportCalled = true
return returnData ?? Data()
}
}@Test
func testJSONSerialization() async throws {
let record = MedicalRecord()
let bloodEntry = BloodEntry(date: Date(), value: "120/80", comment: "Normal")
record.blood.append(bloodEntry)
// Serialize
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(record.blood)
let jsonString = String(data: data, encoding: .utf8)
#expect(jsonString != nil)
#expect(jsonString!.contains("120/80"))
// Deserialize
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode([BloodEntry].self, from: data)
#expect(decoded.count == 1)
#expect(decoded.first?.value == "120/80")
}@Test
func testLargeDatasetPerformance() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: MedicalRecord.self, configurations: config)
let context = container.mainContext
// Create large dataset
let start = Date()
for i in 0..<1000 {
let record = MedicalRecord()
record.personalGivenName = "Person \(i)"
context.insert(record)
}
try context.save()
let elapsed = Date().timeIntervalSince(start)
print("Created 1000 records in \(elapsed) seconds")
#expect(elapsed < 5.0) // Should complete in under 5 seconds
}@Test
func testDebugExample() async throws {
let record = MedicalRecord()
print("Record UUID: \(record.uuid)")
print("Display name: \(record.displayName)")
// Test assertions
}Set breakpoints in test code to inspect state:
- Click line number in Xcode to set breakpoint
- Run test in debug mode
- Inspect variables when breakpoint hits
View test logs in Xcode:
- Run tests
- Open Report Navigator (⌘9)
- Select test run
- View logs and console output
❌ Bad: Tests share state
let sharedRecord = MedicalRecord() // Outside test
@Test
func test1() {
sharedRecord.personalGivenName = "John"
// ...
}
@Test
func test2() {
// Assumes sharedRecord state from test1
}✅ Good: Each test creates its own state
@Test
func test1() {
let record = MedicalRecord()
record.personalGivenName = "John"
// ...
}
@Test
func test2() {
let record = MedicalRecord()
// Independent test
}❌ Bad: Testing private implementation
@Test
func testInternalCacheStructure() {
// Testing internal cache implementation
}✅ Good: Testing public behavior
@Test
func testRecordRetrievalPerformance() {
// Testing observable behavior
}❌ Bad: Tests depend on timing or external factors
@Test
func testFlaky() async throws {
Task {
// Some async operation
}
// Immediately check result without waiting
}✅ Good: Proper async/await usage
@Test
func testStable() async throws {
let result = await someAsyncOperation()
#expect(result != nil)
}- Review Contributing Guide
- Set up Development Environment
- Explore Architecture Overview