How to test a method inside Task Async/await in swift

consider the next class

actor SomActor  {
    public let myVariable: String

    init(){
        Task {  await self.someFunction }
    }

    public func someFunction() async {
             somePublisher.sink {
                  self.myVariable = "testMe"
             }.store(in: &cancellations)
    }
}

now I want to test someFunction was called by checking that myVariable was indeed changed to "testMe".

this code is of-course a simplistic representation to a more complex scenario,
so assume I can't change the code itself.
the main issue is that { self.someFunction } runs a-synchronically. so I can't really wait for it in the test.

please take into consideration:

I hoped there was some XCTest expectation mechanism to help in this case.
( I know expectation predicate could help, but I also don't want to inherit NSObject )

your help is very appreciated!

1 Like

Always interested to see ways of doing stuff in testing.

I am wondering though, could you explain why you would need an unstructured task within an Actor, as it already provides an async context? Is it because its something you want to call in the initializer?

Given the constraints, you don't have a lot of options.

Your unit test could do something like:

let timeout = ContinuousClock.now + .seconds(1)

while await testSubject.myVariable != "testMe" {
    try! Task.sleep(for: .milliseconds(1))
    XCTAssert(.now < timeout)
}

Obviously not ideal in principle, to spin like that, but for a unit test I wouldn't worry too much about it.

1 Like

my wish was to make the code a simple as possible.
someFunction is actually async function ofCourse (due to actor)
and also inside I am using Combine subscriber...
I fixed the code to be more verbose if it helps

1 Like

Ah, that clarifies a lot. Thanks!

thanks @wadetregaskis
I was worried this would be the only solution.
I decided to make my example a bit more verbose ( so you can give it one more look)
How would you make it testable if I allow changing the code?

  1. would you move someFunction outside the init() so it can be triggered synchronically? (I still left to deal with the async code inside of it...)
  2. would you take the subscriber callback and expose it with public function so it can be tested separately?
  3. may be I should introduce an Interactor and move someFunction there. this way I can mock it and at-least make sure it was called... (but then, how can you change myVariable from within the interactor?)

I am curious to know what others mind towards it?

I like to keep unit tests simple and focus on the effects of the code, not its implementation. I know that's not a popular philosophy currently, but I find that tightly coupling tests to code makes them fragile and slows down development.

Given that, nothing needs to change from the example I posted earlier. That you've introduced a Combine pipeline internally doesn't matter.

That said, if I were pushed to tweak it, I'd probably start with having an explicit signalling mechanism from the code, that the unit test could use to detect pertinent changes. e.g.:

actor SomActor  {
    public let myVariable: String {
        didSet {
#if TESTING_ENABLED
            myVariableChange.broadcast()
#endif
        }
    }

#if TESTING_ENABLED
    private var myVariableChange = NSCondition()
#endif

    init(){
        Task {  await self.someFunction }
    }

    public func someFunction() async {
             somePublisher.sink {
                  self.myVariable = "testMe"
             }.store(in: &cancellations)
    }
}

Now your unit test can do something like:

let testSubject = SomActor()
XCTAssert(testSubject.myVariableChange.wait(until: Date.now.advanced(by: 1)),
          "Timed out waiting for myVariable to change on SomActor.")

The caveat with that approach is you need to be sure that blocking the current thread (in the unit test) won't prevent your test subject from doing its thing asynchronously. In the particular example you provide this isn't an issue, because you're using an actor which is guaranteed to have its own unique, independent isolation domain (which roughly equates to an independent thread).

If it weren't an actor - or some part of the asynchronous code did something like explicitly run on the @MainActor - then you'd probably have to not block the main thread (that's running the unit test), so you'd have to revert to the spin-loop approach with a Task.sleep(for:) in there, for example.

I've changed similar code to return/expose the relevant Task somewhere the unit test can get at it, then you can await task.value to be sure that the task body has completed before continuing.

2 Likes

@wadetregaskis thanks for sharing your solution.
it is indeed less popular (to my view) changing the code this way, although it should work.
there is always a tension between the speed of development and readability.
I will be overwhelmed looking at other's code full of if #endif,
but nonetheless, I am inspired by the innovative thinking you shared here - so thanks a-lot

@wadetregaskis
btw, regarding your first proposed solution:

let timeout = ContinuousClock.now + .seconds(1)

while await testSubject.myVariable != "testMe" {
    try! Task.sleep(for: .milliseconds(1))
    XCTAssert(.now < timeout)
}

although I also felt it is not Ideal, I gave it a second thought - isn't it what the wait function does behind the scenes anyway?
so why would it be not ideal actually , taking the devil advocate here (;

It's not ideal insofar as it busy-loops, which just wastes a little CPU time. An ideal implementation (all other things being equal) would simply 'sleep' on a signal, waking up only upon that signal. Like what the later implementation does with NSCondition (although NSCondition is not Swift Concurrency-safe or even runloop-compatible, since it blocks the current thread until the condition is signalled, potentially causing deadlock).

But it's not bad. It will work. And for unit tests and the like the CPU usage shouldn't actually matter. It's just not the sort of code I'd ever want to ship to end users.

1 Like

it is a nice idea, but we need to take into consideration that this is an actor.
so if you do something like this:

actor SomActor  {
    public let myVariable: String
    private(set) var initializationTask: Task<Void, Never>?

    init(){
           self.initializationTask = Task {  await self.someFunction }
    }

    public func someFunction() async {
             somePublisher.sink {
                  self.myVariable = "testMe"
             }.store(in: &cancellations)
    }
}

you will get the warning:

Cannot access property 'initializationTask' here in non-isolated initializer; this is an error in Swift 6

1 Like

@wadetregaskis
off topic question:
would you even call this test a Unitest if we actually waiting for some side effect to finish its job...
it feels for me more like integration test.

What I found helpful is to have a reference to a task that could be used to monitor the progress like:

final class SomeClass  {
    public let myVariable: String

    private(set) var monitoringTask: Task<Void, Never>?

    init() {
        monitoringTask = Task {  await self.someFunction }
    }

    public func someFunction() async {
        somePublisher.sink {
            self.myVariable = "testMe"
        }.store(in: &cancellations)
    }
}

in unit tests you simply await the task

...
func test() async {
    let sut = SomeActor()

    await sut.monitoringTask?.value

    XCTAssertEqual(sut.myVariable, "testMe")
}

I would also include into the discussion another, probably even more tricky use case to unit test related to mixing Combine with Swift Concurrency:

final class IamInconvinentToTest {
    init() {
        somePublisher
            .receive(on: DispatchQueue.global())
            .sink { _ in 
                Task { [weak self] in
                    self?.hardWork()
                }
            }
    }

    private func hardWork() async { ... }
}

your solution will not help in my case since my test class is an actor.
so the compiler wont let you do in the constructor:

           self.initializationTask = Task {  await self.someFunction }

this is why your second use case is in the same complexity as mine.
in both cases, it is not straight forward to hold on the task

1 Like