Unit testing non-async function that uses async function

202 views Asked by At

I have recently updated some code to use Swift concurrency and am unable to work out how to modify my unit tests to support it.

Here's a representation of the code:

protocol AppStartupService {
    func doSomething()
}

class MyClass: AppStartupService {
    
    var dependency: MyClassesDependency
    
    init(dependency: MyClassesDependency) {
        self.dependency = dependency
    }
    
    func doSomething() {
        // This is where the problem is introduced
        // `Task` must be used to avoid marking the surrounding func as `async`
        // however this breaks concurrency for the unit test
        Task {
            await dependency.someAsyncFunc()
            // now the 'didSomethingAsync' flag has been set on a different thread, after the unit test has checked it
        }
    }
}

protocol MyClassesDependency {
    func someAsyncFunc() async
}

While this async function can be used in some instances where it is important to await its completion, in this case it is not important.

Furthermore, the call occurs in a function that cannot be declared async as it is a protocol requirement and this would be a large breaking change.

And representation of the test:

class MyClassesMockDependency: MyClassesDependency {
    
    var didSomethingAsync: Bool = false
    
    func someAsyncFunc() async {
        didSomethingAsync = true
    }
}

/// Verify that the dependency was called by `doSomething()`
func testMyClass() {
    let mockDependency = MyClassesMockDependency()
    let instance = MyClass(dependency: mockDependency)
    
    instance.doSomething()
    
    XCTAssertTrue(mockDependency.didSomethingAsync)
    // fail. It was checked too soon
}

Ideally I'd like to resolve this without

  • Modifying AppStartupService protocol
  • Using some artificial wait time in the unit tests

Can anyone suggest a modification to the test to achieve this?

Alternatively, is there some other bigger picture point I might have missed, or principle I'm violating?

Thanks in advance.

2

There are 2 answers

4
Rob On BEST ANSWER

It strikes me that there are two issues:

  1. Unit tests are for testing your function‘s interface, and your function is one that launches unstructured concurrency without providing any mechanism to know when it is complete. (More on that later.)

    So, it is curious to want to test an interface that your function does not provide. To my eye, it introduces a dissonance between the functionality and what you are testing. I would contend that it violates the very idea of a “unit test”.

  2. The more fundamental question, though, is whether it is prudent to write a function launches an asynchronous task without providing any mechanism to know when that asynchronous task is done. Also, the same question applies to the protocol, namely whether it is good practice to write an interface/protocol where a future developer might not be able to reasonably infer that it is really an asynchronous process.

    Hey, we have all probably done this at one time or another, but, for me at least, more than once I have found myself needing to later refactor that code to add some mechanism to know when the work was done (for the sake of the app itself, not just for tests).

    Personally, as a rule, in our projects, we embrace the design principle that we do not write functions that do not provide a mechanism to know when the asynchronous work is done. We have never regretted that approach, and not-infrequently have thanked ourselves for doing that.

So, in short, I would advise changing the protocol to make it clear it is an asynchronous method. Ideally, I would suggest make it an async function. Or, if you really need it to be a non-async method, then give it an optional completion handler closure parameter. Either way, the protocol will now clearly indicate the underlying asynchronous nature of the method, it is testable, and it provides a mechanism that will likely be useful as your project evolves.

3
matt On

Using some artificial wait time in the unit tests

The key word here is "artificial". The technique is to wait for something specific to be the case. In other words, don't merely add a timed delay; wait for didSomethingAsync to become true.