Section 13/181 menit

13. Testing SwiftData: In-Memory dan Mock Strategies

13. Testing SwiftData: In-Memory dan Mock Strategies

In-Memory ModelContainer untuk Testing

swift
import Testing
import SwiftData

// Helper untuk buat container test yang terisolasi
@MainActor
func makeTestContainer(for types: any PersistentModel.Type...) throws -> ModelContainer {
    let schema = Schema(types)
    let config = ModelConfiguration(
        schema: schema,
        isStoredInMemoryOnly: true  // ← Kunci: in-memory, tidak sentuh disk
    )
    return try ModelContainer(for: schema, configurations: config)
}

// Test dengan Swift Testing framework
@Suite("Task Repository Tests")
struct TaskRepositoryTests {
    
    @Test("Create task berhasil menyimpan ke store")
    @MainActor
    func testCreateTask() async throws {
        let container = try makeTestContainer(for: Task.self)
        let context = container.mainContext
        
        // Act
        let task = Task(title: "Test Task", priority: "high")
        context.insert(task)
        try context.save()
        
        // Assert
        let descriptor = FetchDescriptor<Task>()
        let tasks = try context.fetch(descriptor)
        #expect(tasks.count == 1)
        #expect(tasks.first?.title == "Test Task")
    }
    
    @Test("Unique constraint mencegah duplikat")
    @MainActor
    func testUniqueConstraint() async throws {
        let container = try makeTestContainer(for: Article.self)
        let context = container.mainContext
        
        // Insert pertama
        context.insert(Article(remoteID: "abc123", title: "Original", body: "..."))
        try context.save()
        
        // Insert duplikat — seharusnya upsert
        context.insert(Article(remoteID: "abc123", title: "Updated", body: "..."))
        try context.save()
        
        // Harus tetap 1 record, tapi title terupdate
        let all = try context.fetch(FetchDescriptor<Article>())
        #expect(all.count == 1)
        #expect(all.first?.title == "Updated")
    }
    
    @Test("Delete cascade menghapus children")
    @MainActor
    func testCascadeDelete() async throws {
        let container = try makeTestContainer(for: Author.self, Post.self)
        let context = container.mainContext
        
        // Setup
        let author = Author(name: "Penulis")
        let post1 = Post(title: "Artikel 1", body: "...")
        let post2 = Post(title: "Artikel 2", body: "...")
        author.posts = [post1, post2]
        context.insert(author)
        try context.save()
        
        // Act: hapus author
        context.delete(author)
        try context.save()
        
        // Assert: posts juga terhapus (cascade)
        let remainingPosts = try context.fetch(FetchDescriptor<Post>())
        #expect(remainingPosts.isEmpty)
    }
    
    @Test("Migration V1 ke V2 berhasil split nama")
    func testSchemaV1toV2Migration() async throws {
        // Buat store dengan V1
        let v1Container = try ModelContainer(
            for: SchemaV1.User.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: false,
                                              url: URL.temporaryDirectory.appending(path: "test-v1.store"))
        )
        let v1Context = v1Container.mainContext
        let user = SchemaV1.User(name: "Budi Santoso", email: "budi@example.com")
        v1Context.insert(user)
        try v1Context.save()
        
        // Migrate ke V2
        let v2Container = try ModelContainer(
            for: SchemaV2.User.self,
            migrationPlan: AppMigrationPlan.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: false,
                                              url: URL.temporaryDirectory.appending(path: "test-v1.store"))
        )
        let v2Context = v2Container.mainContext
        let migratedUsers = try v2Context.fetch(FetchDescriptor<SchemaV2.User>())
        
        #expect(migratedUsers.count == 1)
        #expect(migratedUsers.first?.firstName == "Budi")
        #expect(migratedUsers.first?.lastName == "Santoso")
        
        // Cleanup
        try FileManager.default.removeItem(at: URL.temporaryDirectory.appending(path: "test-v1.store"))
    }
}

Dependency Injection untuk Testability

swift
// Protocol untuk testability
protocol TaskRepositoryProtocol {
    func fetchAll() throws -> [Task]
    func create(title: String, priority: String) throws -> Task
    func delete(_ task: Task) throws
}

// Implementasi produksi
final class SwiftDataTaskRepository: TaskRepositoryProtocol {
    private let modelContext: ModelContext
    
    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }
    
    func fetchAll() throws -> [Task] {
        try modelContext.fetch(FetchDescriptor<Task>())
    }
    
    func create(title: String, priority: String) throws -> Task {
        let task = Task(title: title, priority: priority)
        modelContext.insert(task)
        try modelContext.save()
        return task
    }
    
    func delete(_ task: Task) throws {
        modelContext.delete(task)
        try modelContext.save()
    }
}

// Mock untuk testing ViewModel tanpa database
final class MockTaskRepository: TaskRepositoryProtocol {
    var tasks: [Task] = []
    var shouldThrow = false
    
    func fetchAll() throws -> [Task] {
        if shouldThrow { throw MockError.fetchFailed }
        return tasks
    }
    
    func create(title: String, priority: String) throws -> Task {
        if shouldThrow { throw MockError.saveFailed }
        let task = Task(title: title, priority: priority)
        tasks.append(task)
        return task
    }
    
    func delete(_ task: Task) throws {
        tasks.removeAll { $0.persistentModelID == task.persistentModelID }
    }
}

enum MockError: Error {
    case fetchFailed, saveFailed
}