swiftunit-testingtdddebouncing

How to test debouncing logic


I'm trying to use TDD to build a search view model that debounces before searching. Until recently I would have created a mock debouncer, injected it into my view model and used that to control the test. However, I'm trying to make the tests less brittle by only having them test the external implementation as much as possible (and thus less tied to my choice of debouncer if I want to refactor).

This is my current attempt and I have two questions:

  1. Is there a way to test this without having to wait for the specified time? By only allowing myself to test externals, I can't see a way forward without using an actual wait.
  2. If a timer is the way to go, setting the delay in the test to UInt64(0.21 * 1_000_000_000) is flaky: sometimes 0.21 seconds is long enough, other times it's not and I need to increase the wait to 0.22. I'm not keen on that as I'm then no truly testing the debounce time. Can anyone explain why 0.21 would not work?
import XCTest
import UIKit
@testable import SearchViewModel

class SearchViewModelTests: XCTestCase {

    func test_whenSearchingForString_networkRequestIsFiredAfterGivenTime() async throws {
        let spyNetworkService = SpyUrlSession()
        let sut = SearchViewModel(searchApiService: spyNetworkService, searchDebounce: 0.2)
        sut.search(for: "My test string")
        XCTAssertEqual(spyNetworkService.dataTaskCallCount, 0, "The request should not have fired immediately")

        // Wait for debounce time to elapse
        let delay = UInt64(0.22 * 1_000_000_000)
        try await Task.sleep(nanoseconds: delay)

        XCTAssertEqual(spyNetworkService.dataTaskCallCount, 1)
    }
}

protocol URLSessionProtocol {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

struct SearchViewModel {
    
    let urlSession: URLSessionProtocol
    let searchDebounce: Double
    
    init(searchApiService:  URLSessionProtocol = URLSession.shared, searchDebounce: Double) {
        self.urlSession = searchApiService
        self.searchDebounce = searchDebounce
    }
    
    func search(for searchTerm: String) {
        Task {
            try? await Task.sleep(nanoseconds: UInt64(searchDebounce * 1_000_000_000))
            
            guard let url = URL(string: "https://www.my-search-service/\(searchTerm)") else {
                print("Invalid URL")
                return
            }
            
            let request = URLRequest(url: url)
            do {
                let (_, _) = try await urlSession.data(for: request)
            } catch {
                print("Request failed with error: \(error)")
            }
        }
    }
}

extension URLSession: URLSessionProtocol { }

class SpyUrlSession: URLSessionProtocol {
    var dataTaskCallCount = 0
    var lastSentRequest: URLRequest?
    func data(for request: URLRequest) async throws -> (Data, URLResponse) {
        dataTaskCallCount += 1
        lastSentRequest = request
        return (Data(), URLResponse())
    }
}

Many thanks in advance.


Solution

  • You should never use real time in unit tests as they'll make your tests unreliable and really slow. You need to mock time. Either create your own mock Clock or use an existing one such as Swift clocks.