2
\$\begingroup\$

I have a Throttler and Debouncer functions that I took from an unmaintained library. I added my own tests, but not sure if the tests fully prove that it works and can be trusted. If the tests are good, then I’d feel a lot better about using it and can refactor if needed. Below is what I got.

Throttler:

/// The Throttler will ignore work items until the time limit for the preceding call is over.
public final class Throttler {

    // MARK: - Properties

    public let limit: TimeInterval
    public private(set) var lastExecutedAt: Date?

    private let syncQueue = DispatchQueue(label: "com.stackexchange.throttle", attributes: [])

    /// Initialize a new throttler with given time interval.
    ///
    /// - Parameter limit: The number of seconds for throttle work items.
    public init(limit: TimeInterval) {
        self.limit = limit
    }

    /// Submits a work item and ensures it is not called until the delay is completed.
    ///
    /// - Parameter block: The work item to be invoked on the throttler.
    @discardableResult public func execute(_ block: () -> Void) -> Bool {
        let executed = syncQueue.sync { () -> Bool in
            let now = Date()

            // Lookup last executed
            let timeInterval = now.timeIntervalSince(lastExecutedAt ?? .distantPast)

            // If the time since last execution is greater than the limit, execute
            if timeInterval > limit {
                // Record execution
                lastExecutedAt = now

                // Execute
                return true
            }

            return false
        }

        if executed {
            block()
        }

        return executed
    }

    /// Cancels all work items to allow future work items to be executed with the specified delayed.
    public func reset() {
        syncQueue.sync {
            lastExecutedAt = nil
        }
    }
}

Debouncer:

/// The Debouncer will delay work items until time limit for the preceding call is over.
public final class Debouncer {

    // MARK: - Properties

    private let limit: TimeInterval
    private let queue: DispatchQueue

    private var workItem: DispatchWorkItem?
    private let syncQueue = DispatchQueue(label: "com.stackexchange.debounce", attributes: [])

    /// Initialize a new debouncer with given delay limit for work items.
    ///
    /// - Parameters:
    ///   - limit: The number of seconds until the execution block is called.
    ///   - queue: The queue to run the execution block asynchronously.
    public init(limit: TimeInterval, queue: DispatchQueue = .main) {
        self.limit = limit
        self.queue = queue
    }

    /// Submits a work item and ensures it is not called until the delay is completed.
    ///
    /// - Parameter block: The work item to be invoked on the debouncer.
    @objc public func execute(block: @escaping () -> Void) {
        syncQueue.async { [weak self] in
            if let workItem = self?.workItem {
                workItem.cancel()
                self?.workItem = nil
            }

            guard let queue = self?.queue, let limit = self?.limit else { return }

            let workItem = DispatchWorkItem(block: block)
            queue.asyncAfter(deadline: .now() + limit, execute: workItem)

            self?.workItem = workItem
        }
    }

    /// Cancels all work items to allow future work items to be executed with the specified delayed.
    public func reset() {
        syncQueue.async { [weak self] in
            self?.workItem?.cancel()
            self?.workItem = nil
        }
    }
}

Unit Tests:

import XCTest

class RateLimitTests: XCTestCase {

}

// MARK: - Debounce

extension RateLimitTests {

    func testDebouncer() {
        let promise = expectation(description: "Debouncer executed")
        let limiter = Debouncer(limit: 1)
        var currentValue = 0

        limiter.execute {
            currentValue += 1
        }
        XCTAssertEqual(0, currentValue)

        limiter.execute {
            currentValue += 1
        }
        XCTAssertEqual(0, currentValue)

        limiter.execute {
            currentValue += 1
            promise.fulfill()
        }
        XCTAssertEqual(0, currentValue)

        waitForExpectations(timeout: 2)

        XCTAssertEqual(1, currentValue)

        // Sleep for a bit
        sleep(2)

        let promise2 = expectation(description: "Debouncer execute 2")

        limiter.execute {
            currentValue += 1
        }
        XCTAssertEqual(1, currentValue)

        limiter.execute {
            currentValue += 1
            promise2.fulfill()
        }
        XCTAssertEqual(1, currentValue)

        waitForExpectations(timeout: 2)

        XCTAssertEqual(2, currentValue)
    }

    func testDebouncerRunOnceImmediatly() {
        let limiter = Debouncer(limit: 0)
        let promise = expectation(description: "Run once and call immediatly")

        limiter.execute {
            promise.fulfill()
        }

        wait(for: [promise], timeout: 1)
    }

    func testDebouncerRunThreeTimesCountTwice() {
        let limiter = Debouncer(limit: 0.5)

        let promise = expectation(description: "should fulfill three times")
        promise.expectedFulfillmentCount = 3

        limiter.execute {
            promise.fulfill()
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
            limiter.execute {
                promise.fulfill()
            }
        })
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3), execute: {
            limiter.execute {
                promise.fulfill()
            }
        })

        wait(for: [promise], timeout: 5)
    }

    func testDebouncerRunTwiceCountTwice() {
        let limiter = Debouncer(limit: 1)

        let promise = expectation(description: "should fulfill twice")
        promise.expectedFulfillmentCount = 2

        limiter.execute {
            promise.fulfill()
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: {
            limiter.execute {
                promise.fulfill()
            }
        })

        wait(for: [promise], timeout: 3)
    }

    func testDebouncerRunTwiceCountOnce() {
        let limiter = Debouncer(limit: 1)
        let promise = expectation(description: "should fullfile once, because calls are both runned immediatly and second one should get ignored")
        promise.expectedFulfillmentCount = 1

        limiter.execute {
            promise.fulfill()
        }

        limiter.execute {
            promise.fulfill()
        }

        wait(for: [promise], timeout: 5)
    }
}

// MARK: - Throttle

extension RateLimitTests {

    func testThrottler() {
        let limiter = Throttler(limit: 2)

        // It should get excuted first
        let promise1 = expectation(description: "Throttler execute 1")
        var reported = limiter.execute {
            promise1.fulfill()
        }
        XCTAssertTrue(reported)
        waitForExpectations(timeout: 0, handler: nil)

        // Not right away after
        reported = limiter.execute {
            XCTFail("This shouldn't have run.")
        }
        XCTAssertFalse(reported)

        // Sleep for a bit
        sleep(2)

        // Now it should get executed
        let promise2 = expectation(description: "Throttler execute 2")
        reported = limiter.execute {
            promise2.fulfill()
        }
        XCTAssertTrue(reported)
        waitForExpectations(timeout: 0, handler: nil)
    }

    func testThrottler2() {
        let limiter = Throttler(limit: 1)

        let promise = expectation(description: "Run once and call immediately")
        var value = 0

        limiter.execute {
            value += 1
        }

        limiter.execute {
            value += 1
        }

        limiter.execute {
            value += 1
        }

        limiter.execute {
            value += 1
        }

        limiter.execute {
            value += 1
        }

        limiter.execute {
            value += 1
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
            guard value == 1 else {
                XCTFail("Failed to throttle, calls were not ignored.")
                return
            }

            promise.fulfill()
        }

        wait(for: [promise], timeout: 2)
    }

    func testThrottlerResetting() {
        let limiter = Throttler(limit: 1)

        // It should get excuted first
        let promise1 = expectation(description: "Throttler execute 1")
        let reported1 = limiter.execute {
            promise1.fulfill()
        }
        XCTAssertTrue(reported1)
        waitForExpectations(timeout: 0, handler: nil)

        // Not right away after
        let reported2 = limiter.execute {
            XCTFail("This shouldn't have run.")
        }
        XCTAssertFalse(reported2)

        // Reset limit
        limiter.reset()

        // Now it should get executed
        let promise2 = expectation(description: "Throttler execute 2")
        let reported3 = limiter.execute {
            promise2.fulfill()
        }
        XCTAssertTrue(reported3)
        waitForExpectations(timeout: 0, handler: nil)
    }
}

Is there a better, cleaner way to test the throttle and denouncer functionality?

\$\endgroup\$
  • \$\begingroup\$ Are you only asking for a review of your unit tests, or are you asking about the library code as well? The latter may be off-topic, as only code that you own or maintain can be reviewed. \$\endgroup\$ – Martin R Oct 8 '18 at 11:13
  • \$\begingroup\$ I’m asking about the unit test. I can’t grasp the best way to test this and even if the test I have prove that it works properly. \$\endgroup\$ – TruMan1 Oct 8 '18 at 11:18
  • \$\begingroup\$ I’ve updated the description a bit more. Thx \$\endgroup\$ – TruMan1 Oct 8 '18 at 11:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.