15

Given the following method that contains a Task.

  • self.interactor is mocked.
func submitButtonPressed() {
    Task {
        await self.interactor?.fetchSections()
    }
}

How can I write a test to verify that the fetchSections() was called from that method?!

My first thought was to use expectations and wait until it is fulfilled (in mock's code).

But is there any better way with the new async/await?

Kwnstantinos Nikoloutsos
  • 1,832
  • 4
  • 18
  • 34
  • 2
    I'd say the bigger problem is that you're trying to unit test a view controller (I assume `submitButtonPressed` is part of a VC class, based on its name). – Cristik May 04 '22 at 12:59
  • @Cristik Not necessarily. This could easily be an intent in a processor. Note that it isn't an action or `@objc` method, so it cannot be the direct action method of a button. – matt May 04 '22 at 14:17
  • 1
    @matt hence the "I assume" part of my comment. Nonetheless, white-box testing is mostly intended for algorithms, which I assume by looking at the tiny code snippet, is not the case. Neither can it be discussed about gray-box testing, since we don't know the relationship between the unit and the interactor. – Cristik May 05 '22 at 11:24

5 Answers5

7

I don't know if you already find a solution to your question, but here is my contribution to other developers facing the same problem.

I was in the same situation as you, and I solved the problem by using Combine to notify the tested class that the method was called.

Let's say that we have this method to test:

func submitButtonPressed() {
    Task {
        await self.interactor?.fetchSections()
    }
}

We should start by mocking the interaction:

import Combine

final class MockedInteractor: ObservableObject, SomeInteractorProtocol {
    @Published private(set) var fetchSectionsIsCalled = false

    func fetchSection async {
        fetchSectionsIsCalled = true
        // Do some other mocking if needed
    }
}

Now that we have our mocked interactor we can start write unit test:

import XCTest
import Combine
@testable import YOUR_TARGET

class MyClassTest: XCTestCase {
    var mockedInteractor: MockedInteractor!
    var myClass: MyClass!
    private var cancellable = Set<AnyCancellable>()

    override func setUpWithError() throws {
        mockedInteractor = .init()
        // the interactor should be injected
        myClass = .init(interactor: mockedInteractor)
    }

    override func tearDownWithError() throws {
        mockedInteractor = nil
        myClass = nil
    }

    func test_submitButtonPressed_should_callFetchSections_when_Always(){
        //arrage
        let methodCallExpectation = XCTestExpectation()
        
        interactor.$fetchSectionsIsCalled
            .sink { isCalled in
                if isCalled {
                    methodCallExpectation.fulfill()
                }
            }
            .store(in: &cancellable)
        
        //acte
        myClass.submitButtonPressed()
        wait(for: [methodCallExpectation], timeout: 1)
        
        //assert
        XCTAssertTrue(interactor.fetchSectionsIsCalled)
    }
  • 1
    Great answer, thank you for the idea! In my case, it still did not work entirely. `func fetchSection() async` returned some values and ViewModel did some job based on the result. But waiting for expectation got resolved before any other job has been done. Same also with defer. So I had to wrap the publishing in a Task. It's still better than flaky constant polling with Nimble's toEventually, so thank again :) – Pavel Stepanov Jul 08 '23 at 13:23
7

Late to the party but this worked for me

    let task = Task {
        sut.submitButtonPressed()
    }
    await task.value
    XCTAssertTrue(mockInteractor.fetchSectionsWasCalled)
Andre White
  • 81
  • 1
  • 1
  • This is really good, should be accepted answer – Osa Mar 25 '23 at 02:03
  • Nice idea! For me, this solution did not work in a bit more complex scenario where there are things to be done after await. Meaning `await task.value` continues with the test as soon as it invokes `await` from `submitButtonPressed`. Unfortunately, StackOverflow cannot handle multiline comments so the example code is there: https://gist.github.com/dyrkabes/b17e93f154df49659d1af71f31da2d71 – Pavel Stepanov Jul 11 '23 at 12:10
  • Worked for me, thanks! – mucahid-erdogan Aug 19 '23 at 14:18
3

There was one solution suggested here (@andy) involving injecting the Task. There's a way to do this by the func performing the task returning the Task and allows a test to await the value.
(I'm not crazy about changing a testable class to suit the test (returning the Task), but it allows to test async without NSPredicate or setting some arbitrary expectation time (which just smells)).

@discardableResult
func submitButtonPressed() -> Task<Void, Error> {
    Task { // I'm allowed to omit the return here, but it's returning the Task
        await self.interactor?.fetchSections()
    }
}

// Test
func testSubmitButtonPressed() async throws {
    
    let interactor = MockInteractor()
    
    let task = manager.submitButtonPressed()
    
    try await task.value
    
    XCTAssertEqual(interactor.sections.count, 4)
}
bauerMusic
  • 5,470
  • 5
  • 38
  • 53
0

Ideally, as you imply, your interactor would be declared using a protocol so that you can substitute a mock for test purposes. You then consult the mock object to confirm that the desired method was called. In this way you properly confine the scope of the system under test to answer only the question "was this method called?"

As for the structure of the test method itself, yes, this is still asynchronous code and, as such, requires asynchronous testing. So using an expectation and waiting for it is correct. The fact that your app uses async/await to express asynchronousness does not magically change that! (You can decrease the verbosity of this by writing a utility method that creates a BOOL predicate expectation and waits for it.)

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 3
    How would you use an expectation when the function has no completion block? I suppose you could just wait for an arbitrary amount of time, but that seems fragile. – johnny May 13 '22 at 18:43
  • I'm also interested in this. How would the expectation look? I'm guessing the expectation is fulfilled when the mock's implementation of `fetchSections` in this case gets called, but this seems a bit cumbersome to achieve, especially in a test when `submitButtonPressed` gets called multiple times. – vrutberg May 18 '22 at 16:02
  • @vrutberg No, keep your eye on the SUT. You only need to test `submitButtonPressed` once — to make sure it calls `fetchSections`. You might have lots of tests of `fetchSections` under different circumstances but now you're in a different world. – matt May 18 '22 at 16:46
  • 1
    @johnnykehr The way we do it is that the mock interactor sets its own property to indicate that a method got called. So we just wait for that. – matt May 18 '22 at 16:47
  • 1
    Ok. Imagine if the implementation you wanted to test was this: ` @MainActor` ` func submitButtonPressed() {` `Task {` `let sections = await self.interactor?.fetchSections()` `sectionCount = sections.count()` `}` `}` How would you test that the `sectionCount` variable was set? Even if you use and expectation to wait until your interactior protocol mock is called, that expectation will fulfil before the `sectionCount` is set. I'm guessing we need to inject the Task.init when testing and manually control its execution? – Andy Jun 22 '22 at 17:58
  • 1
    @Andy You just use asynchronous testing, as I already suggested, no...? The expectation predicate would be that the section count variable _was_ set. – matt Jun 22 '22 at 18:10
  • @matt Hey, thanks for the tip. I did not know about that XCTest feature. But it ends up making my unit tests ssslow. Looks like the predicate is checked only once per second. I think if I capture the operation in the closure, I can await it in the test and the test will finish much faster. Or is there a way to speed up the predicate checking interval? – Andy Jun 23 '22 at 05:28
  • This is depressing. Hopefully we'll get a test dispatch queue or something, like Kotlin. – funct7 Aug 22 '22 at 12:35
  • @Andy "Looks like the predicate is checked only once per second" That's not my experience at all. – matt Aug 22 '22 at 13:09
-2

I answered a similar question in this post: https://stackoverflow.com/a/73091753/2077405

Basically, given code defined like this:

class Owner{
   let dataManager: DataManagerProtocol = DataManager()
   var data: String? = nil

   init(dataManager: DataManagerProtocol = DataManager()) {
      self.dataManager = dataManager
   }

   func refresh() {
        Task {
            self.data = await dataManager.fetchData()
        }
    }
}

and the DataManagerProtocol is defined as:

protocol DataManagerProtocol {
   func fetchData() async -> String
}

a mock/fake implementation can be defined:

class MockDataManager: DataManagerProtocol {
    func fetchData() async -> String {
       "testData"
    }
}

Implementing the unit test should go like this:

...
func testRefreshFunctionFetchesDataAndPopulatesFields() {        
  let expectation = XCTestExpectation(
    description: "Owner fetches data and updates properties."
  )
 
  let owner = Owner(mockDataManager: DataManagerProtocol())
        
  // Verify initial state
  XCTAssertNil(owner.data)

  owner.refresh()
        
  let asyncWaitDuration = 0.5 // <= could be even less than 0.5 seconds even
  DispatchQueue.main.asyncAfter(deadline: .now() + asyncWaitDuration) {
    // Verify state after
    XCTAssertEqual(owner.data, "testData")

    expectation.fulfill()
  }

  wait(for: [expectation], timeout: asyncWaitDuration)
}
...

Hope this makes sense?

Ugo
  • 587
  • 8
  • 12
  • 1
    Your test is not entirely correct, the expectation should not be fulfilled by the test. You can simply remove the expectation code as it adds no value, you'd get the same testing behaviour in all cases. – Cristik Jul 24 '22 at 05:09
  • @Cristik So, here's Apple's own guide on testing Async code which mentions using `XCTestExpectation` like I did above: https://developer.apple.com/documentation/xctest/asynchronous_tests_and_expectations#3927086 It says: "When you can’t use Swift async, use expectations to test asynchronous code...Start the asynchronous task, and then tell the test to wait for the expectation to complete within an amount of time you specify. If the test doesn’t execute the `fulfill()` method before the wait statement’s timeout expires, XCTest records a test failure." – Ugo Jul 25 '22 at 10:31
  • Yeah, it might seem useless but it's idiomatically appropriate as per Apple's guide. No? – Ugo Jul 25 '22 at 10:33
  • 1
    `it's idiomatically appropriate as per Apple's guide` - no, it's not, not the way you're using it. The `fulfill()` call needs to be done in the completion callback of the tested code, see the example with `openFileAsync()` - the expectation fulfillment is done in the callback of the function being tested. – Cristik Jul 25 '22 at 10:57