Section 12/161 menit

12. Testing Concurrent Code

12. Testing Concurrent Code

Tantangan Testing Async Code

swift
import Testing

// MARK: - Testing async functions

@Suite("Async API Tests")
struct AsyncTests {
    @Test("Fetch user berhasil")
    func testFetchUser() async throws {
        let service = UserService()
        let user = try await service.fetchUser(id: "123")
        #expect(user.name == "Expected Name")
    }

    // Test dengan timeout
    @Test("Network call selesai dalam 5 detik", .timeLimit(.seconds(5)))
    func testNetworkTimeout() async throws {
        let service = NetworkService()
        let result = try await service.fetchData(from: testURL)
        #expect(!result.isEmpty)
    }
}

var testURL: URL { URL(string: "https://example.com")! }
struct UserService { func fetchUser(id: String) async throws -> User { User(id: id, name: "Expected Name") } }
struct NetworkService { func fetchData(from url: URL) async throws -> Data { Data() } }

Testing Actor State

swift
// Test yang verifikasi actor isolation
@Suite("Actor Tests")
struct ActorTests {
    @Test("Concurrent writes tidak menyebabkan data corruption")
    func testConcurrentWrites() async throws {
        let counter = SafeCounter()

        // Launch 100 concurrent tasks
        await withTaskGroup(of: Void.self) { group in
            for _ in 0..<100 {
                group.addTask {
                    await counter.increment()
                }
            }
        }

        let finalCount = await counter.value
        #expect(finalCount == 100, "Seharusnya tepat 100 setelah 100 increments")
    }

    @Test("Reentrancy tidak menyebabkan double-fetch")
    func testNoDoubleFetch() async throws {
        let cache = TrackingCache()

        // Concurrent access untuk key yang sama
        async let r1 = cache.getValue(for: "key1")
        async let r2 = cache.getValue(for: "key1")

        let (v1, v2) = try await (r1, r2)
        #expect(v1 == v2)

        let fetchCount = await cache.fetchCount
        #expect(fetchCount == 1, "Key yang sama hanya boleh di-fetch sekali")
    }
}

actor SafeCounter {
    private(set) var value = 0
    func increment() { value += 1 }
}

actor TrackingCache {
    private var data: [String: String] = [:]
    private var inFlight: [String: Task<String, Error>] = [:]
    private(set) var fetchCount = 0

    func getValue(for key: String) async throws -> String {
        if let cached = data[key] { return cached }
        if let existing = inFlight[key] { return try await existing.value }

        let task = Task { [weak self] () throws -> String in
            guard let self else { throw CacheError.deallocated }
            // Simulate network fetch
            try await Task.sleep(for: .milliseconds(10))
            await self.recordFetch(key: key)
            return "value-\(key)"
        }
        inFlight[key] = task

        let result = try await task.value
        data[key] = result
        inFlight.removeValue(forKey: key)
        return result
    }

    private func recordFetch(key: String) {
        fetchCount += 1
        data[key] = "value-\(key)"
    }
}

Testing dengan Fake Clock

swift
// Test time-based logic tanpa menunggu waktu nyata
// Menggunakan TestClock dari Point-Free's swift-clocks package

@Test("Retry exponential backoff menunggu waktu yang benar")
func testRetryBackoff() async throws {
    // Dengan TestClock, kita bisa advance waktu manual
    // tanpa menunggu detik/menit nyata

    var attempts: [Date] = []

    // Biasanya pakai: let clock = TestClock()
    // Di sini kita mock dengan ContinuousClock untuk simplifikasi
    let clock = ContinuousClock()
    let maxAttempts = 3

    do {
        try await retryWithBackoff(
            clock: clock,
            maxAttempts: maxAttempts,
            initialDelay: .milliseconds(10)  // kecil untuk test cepat
        ) {
            attempts.append(Date())
            throw TestError.alwaysFail
        }
    } catch {}

    #expect(attempts.count == maxAttempts)
}

enum TestError: Error { case alwaysFail }

func retryWithBackoff<C: Clock>(
    clock: C,
    maxAttempts: Int,
    initialDelay: C.Duration,
    operation: () async throws -> Void
) async throws where C.Duration == Duration {
    var delay = initialDelay
    for attempt in 1...maxAttempts {
        do {
            try await operation()
            return
        } catch {
            guard attempt < maxAttempts else { throw error }
            try await Task.sleep(for: delay, clock: clock)
            delay = delay * 2
        }
    }
}