Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How Can I Unit Test Swift Timer Controller?

I am working a project that will utilize Swift's Timer class. My TimerController class will control a Timer instance by starting, pausing, resuming, and resetting it.

TimerController consists of the following code:

internal final class TimerController {

    // MARK: - Properties

    private var timer = Timer()
    private let timerIntervalInSeconds = TimeInterval(1)
    internal private(set) var durationInSeconds: TimeInterval

    // MARK: - Initialization

    internal init(seconds: Double) {
        durationInSeconds = TimeInterval(seconds)
    }

    // MARK: - Timer Control

    // Starts and resumes the timer
    internal func startTimer() {
        timer = Timer.scheduledTimer(timeInterval: timerIntervalInSeconds, target: self, selector: #selector(handleTimerFire), userInfo: nil, repeats: true)
    }

    internal func pauseTimer() {
        invalidateTimer()
    }

    internal func resetTimer() {
        invalidateTimer()
        durationInSeconds = 0
    }

    // MARK: - Helpers

    @objc private func handleTimerFire() {
        durationInSeconds += 1
    }

    private func invalidateTimer() {
        timer.invalidate()
    }

}

Currently, my TimerControllerTests contains the following code:

class TimerControllerTests: XCTestCase {

    func test_TimerController_DurationInSeconds_IsSet() {
        let expected: TimeInterval = 60
        let controller = TimerController(seconds: 60)
        XCTAssertEqual(controller.durationInSeconds, expected, "'durationInSeconds' is not set to correct value.")
    }

}

I am able to test that the timer's expected duration is set correctly when initializing an instance of TimerController. However, I don't know where to start testing the rest of TimerController.

I want to ensure that the class successfully handles startTimer(), pauseTimer(), and resetTimer(). I want my unit tests to run as quickly as possible, but I think that I need to actually start, pause, and stop the timer to test that the durationInSeconds property is updated after the appropriate methods are called.

Is it appropriate to actually create the timer in TimerController and call the methods in my unit tests to verify that durationInSeconds has been updated correctly?

I realize that it will slow my unit tests down, but I don't know of another way to appropriately test this class and it's intended actions.

Update

I have been doing some research, and I have found, what I think to be, a solution that seems to get the job done as far as my testing goes. However, I am unsure whether this implementation is sufficient.

I have reimplemented my TimerController as follows:

internal final class TimerController {

    // MARK: - Properties

    private var timer = Timer()
    private let timerIntervalInSeconds = TimeInterval(1)
    internal private(set) var durationInSeconds: TimeInterval
    internal var isTimerValid: Bool {
        return timer.isValid
    }

    // MARK: - Initialization

    internal init(seconds: Double) {
        durationInSeconds = TimeInterval(seconds)
    }

    // MARK: - Timer Control

    internal func startTimer(fireCompletion: (() -> Void)?) {
        timer = Timer.scheduledTimer(withTimeInterval: timerIntervalInSeconds, repeats: true, block: { [unowned self] _ in
            self.durationInSeconds -= 1
            fireCompletion?()
        })
    }

    internal func pauseTimer() {
        invalidateTimer()
    }

    internal func resetTimer() {
        invalidateTimer()
        durationInSeconds = 0
    }

    // MARK: - Helpers

    private func invalidateTimer() {
        timer.invalidate()
    }

}

Also, my test file has passing tests:

class TimerControllerTests: XCTestCase {

    // MARK: - Properties

    var timerController: TimerController!

    // MARK: - Setup

    override func setUp() {
        timerController = TimerController(seconds: 1)
    }

    // MARK: - Teardown

    override func tearDown() {
        timerController.resetTimer()
        super.tearDown()
    }

    // MARK: - Time

    func test_TimerController_DurationInSeconds_IsSet() {
        let expected: TimeInterval = 60
        let timerController = TimerController(seconds: 60)
        XCTAssertEqual(timerController.durationInSeconds, expected, "'durationInSeconds' is not set to correct value.")
    }

    func test_TimerController_DurationInSeconds_IsZeroAfterTimerIsFinished() {
        let numberOfSeconds: TimeInterval = 1
        let durationExpectation = expectation(description: "durationExpectation")
        timerController = TimerController(seconds: numberOfSeconds)
        timerController.startTimer(fireCompletion: nil)
        DispatchQueue.main.asyncAfter(deadline: .now() + numberOfSeconds, execute: {
            durationExpectation.fulfill()
            XCTAssertEqual(0, self.timerController.durationInSeconds, "'durationInSeconds' is not set to correct value.")
        })
        waitForExpectations(timeout: numberOfSeconds + 1, handler: nil)
    }

    // MARK: - Timer State

    func test_TimerController_TimerIsValidAfterTimerStarts() {
        let timerValidityExpectation = expectation(description: "timerValidity")
        timerController.startTimer {
            timerValidityExpectation.fulfill()
            XCTAssertTrue(self.timerController.isTimerValid, "Timer is invalid.")
        }
        waitForExpectations(timeout: 5, handler: nil)
    }

    func test_TimerController_TimerIsInvalidAfterTimerIsPaused() {
        let timerValidityExpectation = expectation(description: "timerValidity")
        timerController.startTimer {
            self.timerController.pauseTimer()
            timerValidityExpectation.fulfill()
            XCTAssertFalse(self.timerController.isTimerValid, "Timer is valid")
        }
        waitForExpectations(timeout: 5, handler: nil)
    }

    func test_TimerController_TimerIsInvalidAfterTimerIsReset() {
        let timerValidityExpectation = expectation(description: "timerValidity")
        timerController.startTimer {
            self.timerController.resetTimer()
            timerValidityExpectation.fulfill()
            XCTAssertFalse(self.timerController.isTimerValid, "Timer is valid")
        }
        waitForExpectations(timeout: 5, handler: nil)
    }

}

The only thing that I can think of to make the tests faster is for me to mock the class and change let timerIntervalInSeconds = TimeInterval(1) to private let timerIntervalInSeconds = TimeInterval(0.1).

Is it overkill to mock the class so that I can use a smaller time interval for testing?

like image 725
Nick Kohrn Avatar asked Feb 04 '26 00:02

Nick Kohrn


1 Answers

Rather than use a real timer (which would be slow), we can verify calls to a test double.

The challenge is that the code calls a factory method, Timer.scheduledTimer(…). This locks down a dependency. Testing would be easier if the test could provide a mock timer instead.

Usually, a good way to inject a factory is by supplying a closure. We can do this in the initializer, and provide a default value. Then the closure, by default, will make the actual call to the factory method.

In this case, it's a little complicated because the call to Timer.scheduledTimer(…) itself takes a closure:

internal init(seconds: Double,
              makeRepeatingTimer: @escaping (TimeInterval, @escaping (TimerProtocol) -> Void) -> TimerProtocol = {
                  return Timer.scheduledTimer(withTimeInterval: $0, repeats: true, block: $1)
              }) {
    durationInSeconds = TimeInterval(seconds)
    self.makeRepeatingTimer = makeRepeatingTimer
}

Note that I removed all references to Timer except inside the block. Everywhere else uses a newly-defined TimerProtocol.

self.makeRepeatingTimer is a closure property. Call it from startTimer(…).

Now test code can supply a different closure:

class TimerControllerTests: XCTestCase {
    var makeRepeatingTimerCallCount = 0
    var lastMockTimer: MockTimer?

    func testSomething() {
        let sut = TimerController(seconds: 12, makeRepeatingTimer: { [unowned self] interval, closure in
            self.makeRepeatingTimerCallCount += 1
            self.lastMockTimer = MockTimer(interval: interval, closure: closure)
            return self.lastMockTimer!
        })

        // call something on sut

        // verify against makeRepeatingTimerCallCount and lastMockTimer
    }
}
like image 141
Jon Reid Avatar answered Feb 05 '26 14:02

Jon Reid



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!