Home > Mobile >  XCTest testing asyncronous Combine @Publishers [duplicate]
XCTest testing asyncronous Combine @Publishers [duplicate]

Time:09-23

I'm working on an iOS app (utilizing Swift, XCTest, and Combine) trying to test a function within my view model, which is calling and setting a sink on a publisher. I'd like to test the view model, not the publisher itself. I really don't want to use DispatchQueue.asyncAfter( because theoretically I don't know how long the publisher will take to respond. For instance, how would I test XCTAssertFalse(viewModel.isLoading)

class ViewModel: ObservableObject {
  @Published var isLoading: Bool = false
  @Published var didError: Bool = false
  var dataService: DataServiceProtocol

  init(dataService: DataServiceProtocol) {
    self.dataService = dataService
  }

  func getSomeData() { // <=== This is what I'm hoping to test
    isLoading = true
    dataService.getSomeData() //<=== This is the Publisher
      .sink { (completion) in
        switch completion {
          case .failure(_):
            DispatchQueue.main.async {
              self.didError = true
            }
          case .finished:
            print("finished")
         }
                        
       DispatchQueue.main.async {
         self.isLoading = false
       }
     } receiveValue: { (data) in
         print("Ok here is the data", data)
    }
  }
}

I'd like to write a test that might look like:

func testGetSomeDataDidLoad() {
  // this will test whether or not getSomeData
  // loaded properly

  let mockDataService: DataServiceProtocol = MockDataService
  let viewModel = ViewModel(dataService: mockDataService)

  viewModel.getSomeData()

  // ===== THIS IS THE PROBLEM...how do we know to wait for getSomeData? ======
  // It isn't a publisher...so we can't listen to it per se... is there a better way to solve this?

  XCTAssertFalse(viewModel.isLoading)
  XCTAssertFalse(viewModel.didError)
}

Really hoping to refactor our current tests so we don't utilize a DispatchQueue.asyncAfter(

CodePudding user response:

I suggest you look at the combine-schedulers package. With that package, you can change ViewModel to take an AnySchedulerOf<DispatchQueue> argument:

class ViewModel: ObservableObject {
  @Published var isLoading: Bool = false
  @Published var didError: Bool = false
  var dataService: DataServiceProtocol
  var scheduler: AnySchedulerOf<DispatchQueue>

  init(
    dataService: DataServiceProtocol,
    scheduler: AnySchedulerOf<DispatchQueue>
  ) {
    self.dataService = dataService
    self.scheduler = scheduler
  }

  func getSomeData() {
    isLoading = true
    dataService
      .getSomeData()

      // ********
      // We use the scheduler instead of DispatchQueue.main.
      // ********
      .receive(on: scheduler)

      .sink { (completion) in
        switch completion {
        case .failure(_):
          self.didError = true
        case .finished:
          print("finished")
        }
        self.isLoading = false
      }
    } receiveValue: { (data) in
         print("Ok here is the data", data)
    }
  }
}

When you create your ViewModel in a production environment, you pass in the real DispatchQueue.main:

let viewModel = ViewModel(
  dataService: LiveDataService(),
  scheduler: DispatchQueue.main.eraseToAnyScheduler()
)

In your test, you pass in a TestSchedulerOf<DispatchQueue>, and drive the flow of time manually:

func testGetSomeDataDidLoad() {
  let mockDataService: DataServiceProtocol = MockDataService()
  let clock = DispatchQueue.test
  let viewModel = ViewModel(
    dataService: mockDataService,
    scheduler: clock    
  )

  viewModel.getSomeData()

  // Time hasn't advanced yet, so the model is still loading:
  XCTAssertTrue(viewModel.isLoading)
  XCTAssertFalse(viewModel.didError)

  // Assuming MockDataService completed synchronously, there are actions
  // queued in the test scheduler.
  // Run the test scheduler until there are not pending actions left.
  clock.run()

  // Now viewModel should have finished loading.
  XCTAssertFalse(viewModel.isLoading)
  XCTAssertFalse(viewModel.didError)
}

CodePudding user response:

Yeah, everybody's saying, MVVM increases testability. Which is hugely true, and thus a recommended pattern. But, how you test View Models is shown only very rarely in tutorials. So, how can we test this thing?

The basic idea testing a view model is using a mock which can do the following:

  1. The mock must record changes in its output (which is the published properties)
  2. Record a change of the output
  3. Apply an assertion function to the output
  4. Possibly record more changes

In order to work better with the following tests, refactor your ViewModel slightly, so it gets a single value representing your view state, using a struct:

    final class MyViewModel {
        struct ViewState {
            var isLoading: Bool = false 
            var didError: Bool = false
        }

        @Published private(set) var viewState: ViewState = .init()

        ...
    }

Then, define a Mock for your view. You might try something like this, which is a pretty naive implementation:

The mock view also gets a list of assertion functions which test your view state in order.

    class MockView {
        var viewModel: MyViewModel
        var cancellable = Set<AnyCancellable>()

        typealias AssertFunc = (MyViewModel.ViewState) -> Void
        let asserts: ArraySlice<AssertFunc>

        private var next: AssertFunc? = nil

        init(viewModel: MyViewModel, asserts: [AssertFunc]) {
            self.viewModel = viewModel
            self.asserts = ArraySlice(asserts)
            self.next = asserts.first
            viewModel.$viewState
                .sink { newViewState in
                    self.next?(newViewState)
                    self.next = self.asserts.dropFirst().first
                }
        }
    }

You may setup the mock like this:

let mockView = MockView(
    viewModel: viewModel, 
    asserts: [
        { state in 
            XCTAssertEqual(state.isLoading, false)
            XCTAssertEqual(state.didError, false)
        },
        { state in 
            XCTAssertEqual(state.isLoading, true)
            ...
        },
        ...
    ])

You can also use XCT expectation in the assert functions.

Then, in your test you create the view model, your mock data service and the configured mockView.

  let mockDataService: DataServiceProtocol = MockDataService
  let viewModel = ViewModel(dataService: mockDataService)
  let mockView = MockView(
    viewModel: viewModel, 
    asserts: [
        { state in 
            XCTAssertEqual(state.isLoading, false)
            XCTAssertEqual(state.didError, false)
        },
        ...
        { state in 
            XCTAssertEqual(state.isLoading, false)
            XCTAssertEqual(state.didError, false)
            expectFinished.fulfill()
        },
        ...
    ])

    viewModel.getSomeData()
    
    // wait for the expectations

Caution: I didn't compile or run the code.

You may also take a look at Entwine

  • Related