skip to Main Content

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?

5

Answers


  1. 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.)

    Login or Signup to reply.
  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?

    Login or Signup to reply.
  3. 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)
        }
    
    Login or Signup to reply.
  4. 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)
    }
    
    Login or Signup to reply.
  5. Late to the party but this worked for me

        let task = Task {
            sut.submitButtonPressed()
        }
        await task.value
        XCTAssertTrue(mockInteractor.fetchSectionsWasCalled)
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search