skip to Main Content

I have a set up like this:

class ViewModel: ObservableObject {
    @Published var data: [Int] = []

    func fetchData() {
        someAPICall() { result in
            data = result // Assume 10 items get inserted here
        }
    }
}

I write a test like so:

func testViewModel() {
    let waitExpectation = expectation(description: "testVM")
    let viewModel = ViewModel()
    viewModel.$contentUnitViewModels
    .dropFirst() // Ignore the publish that happens when the VM is initialized
    .sink { data in
        waitExpectation.fulfill()
        XCTAssertEqual(viewModel.data.count, 10)
        // viewModel.data is empty but data has the expected 10 values
    }
    .store(in: &cancellables)

    viewModel.fetchData()

    waitForExpectations(timeout: waitExpectationTimeout) { error in
        XCTAssertNil(error)
    }

}

The issue is that when I used the published property data with a SwiftUI view, the data gets rendered / loaded as expected.

However, during tests, for some reason, viewModel.data is empty and viewModel.data.count is 0.

Funnily enough, the data property passed within the completion handler has 10 values.

It seems that the publish happens before the actual underlying array is set.

Is there anyway to fix this / improve my code so that I only get notified when the actual underlying array is set ?

2

Answers


  1. Chosen as BEST ANSWER

    The answer by Malhal (using async await) and the suggestions by Sweeper (completion handler) are the actual answers, however, this would mean me refactoring a lot of old code so I did have to work with the @Published property.

    What I did just for the test was to add a delay prior to processing the data which was sufficient for the value to be set.

    This is not the ideal answer, however, wanted to share the approach I went with.

    viewModel.$contentUnitViewModels
        .dropFirst() // Ignore the publish that happens when the VM is initialized
        .delay(for: .milliseconds(100), scheduler: RunLoop.main) // this delay
        .sink { data in
            waitExpectation.fulfill()
            XCTAssertEqual(viewModel.data.count, 10)
            // viewModel.data is empty but data has the expected 10 values
        }
        .store(in: &cancellables)
    

  2. You could improve it by using task then you don’t need the object, eg

    .task {
        results = await controller.fetch()
    }
    

    The advantage with task is it runs on appear and cancels on dissapear, which you forgot to implement in your object. Also, now the async func in the controller can be tested.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search