XCTest testing asyncronous Combine #Publishers [duplicate] - ios

This question already has answers here:
How To UnitTest Combine Cancellables?
(2 answers)
Closed 1 year ago.
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(

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:
The mock must record changes in its output (which is the published properties)
Record a change of the output
Apply an assertion function to the output
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

How to load data concurrently with TaskGroup in Swift

I have array of objects 'Games' some fields of game have ids property of different types of objects. I need to load these object async by ids, to have fully loaded game with objects.
struct Game: Codable {
var field1: Int
var field2: Int
var field1Object: Object1?
var field2Object: Object2?
.....
}
func fetchData(_ games: [Game]) async -> [Game] {
await withTaskGroup(of: Game.self) { group in
var updatedGames = [Game]()
updatedGames.reserveCapacity(games.count)
for game in games {
group.addTask {
let field1Object = await loadObjectBy(game.field1)
game.field1Object = field1Object
return game
}
group.addTask {
let field2Object = await loadObjectBy(game.field2)
game.field2Object = field2Object
return game
}
}
for await updatedGame in group {
updatedGames.append(updatedGame)
}
return updatedGames
}
}
I have error 'game is let constant', How to correctly load object? Can I modify game in addTask closure?
The error "game is let constant" is a duplicated issue. For example, see this one:
https://stackoverflow.com/a/74603997/18557672
game is the element iterated inside the loop, it's just a copy, and of course it only exists inside the loop's closure.
To access and modify a specific element of the "games" var inside the closure, you need to implement your code with that in mind. You could maybe use (as suggested by #Rob):
for index in games.indices {
...
games[index]
...
}
You should try to fix that issue first, and then, if you still have problems with the original task, try to edit the question with the updated info.

Swift Combine sink called once at setup?

I am setting up a sink like so:
name.publisher
.removeDuplicates()
.receive(on: RunLoop.main)
.sink { [weak self] config in
guard let self = self else { return }
// this closure gets called right away at setup even though the #Published property `name` was already setup and did not change
}.store(in: &subscribers)
The property is declared like so in an observable object:
#Published var name:String = ""
So, I'm obviously missing something here. Why is sink called once at setup even though name did not change? I can avoid this behavior by using the dropFirst() operator but, I'd like to understand why the closure is always called once immediately after setup?
Why is that?
Here's a playground that uses debugPrint to show you what you get from name.publisher:
import UIKit
import Combine
//Holds the score
class ViewModel : ObservableObject {
#Published var name = "0"
}
let viewModel = ViewModel()
debugPrint(viewModel.name.publisher)
What you get is
Combine.Publishers.Sequence<Swift.String, Swift.Never>(sequence: "0")
So you get a sequence publisher that has a single item, "0". It will publish that value once and the sequence will end. Each time a subscriber attaches to that sequence it will get all the items in the sequence (there's only one) and the end.
This is probably not what you want.
Instead I think you want to use $name to access the published property:
import UIKit
import Combine
//Holds the score
class ViewModel : ObservableObject {
#Published var name = "0"
}
let viewModel = ViewModel()
let subscription = viewModel.$name.sink { print($0) }
viewModel.name = "Alex"
When you subscribe to the published property you will still get a posted event that is the current value of the property. However, by using $name you are attaching to a stream that will send you the current value on subscription AND each subsequent value.

Best practice to observe livedata from data class

I need to observe a LiveData object from a data class. I have observed these two methods are working:
Method 1: Convert the class to a LifecycleOwner
class MyObservingClass: LifecycleOwner {
private val lifecycleRegistry: LifecycleRegistry
var receivedData: String = ""
init {
lifecycleRegistry = LifecycleRegistry(this)
Server.dataObject.observe(this) {
receivedData = it
Log.d(DEBUG_TAG, "observed : $it")
}
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
override fun getLifecycle(): Lifecycle {
return lifecycleRegistry
}
}
Method 2: Use observeForever
class MyObservingClass {
var receivedData: String = ""
init {
Server.dataObject.observeForever {
receivedData = it
Log.d(DEBUG_TAG, "observed : $it")
}
}
}
Then I am using it as val obj = MyObservingClass() in some other place. As previously stated both of these approaches are working and I can observe the LiveData.
My question is, will these two methods prevent garbage collection of the objects as they are observing indefinitely? Are both these methods flawed? I am making an Android library and don't have life-cycle aware components like Activity.
Thanks for the replies :)

Property Wrapper for CurrentValueSubject - memory management

I would like to create a property wrapper for CurrentValueSubject. I have done this like that:
#propertyWrapper
public class CurrentValue<Value> {
public var wrappedValue: Value {
get { projectedValue.value }
set { projectedValue.value = newValue }
}
public var projectedValue: CurrentValueSubject<Value, Never>
public init(wrappedValue: Value) {
self.projectedValue = CurrentValueSubject(wrappedValue)
}
}
This works but there is a little thing I would like to change with it - use struct instead of class. The problem with using struct for this is that sometimes I could get Simultaneous accesses error. And I know why, this happens when in sink from this publisher I would try to read the value from wrapped value. So for example with code like this:
#CurrentValue
let test = 1
$test.sink { _ in
print(self.test)
}
And I more or less know why - because when projectedValue executes its observation, wrapped value is still in process of setting its value. In class this is ok, because it would just change the value, but with struct it actually modifies the struct itself, so Im trying to write and read from it at the same time.
My question is - is there some clever way to overcome this, while still using struct? I don't want to dispatch async.
Also I know that #Projected works similarly to this propertyWrapper, but there is a big difference - Projected executes on willSet, while CurrentValueSubject on didSet. And Projected has the same issue anyway.
I know that I can read the value inside the closure, but sometimes Im using this with various function calls, that might eventually use self.test instead.
Try my implementation
#propertyWrapper
class PublishedSubject<T> {
var wrappedValue: T {
didSet {
subject.send(wrappedValue)
}
}
var projectedValue: some Publisher<T, Never> {
subject
}
private lazy var subject = CurrentValueSubject<T, Never>(wrappedValue)
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}

#Published for a computed property (or best workaround)

I'm trying to build an app with SwiftUI, and I'm just getting started with Combine framework. My first simple problem is that I'd like a single variable that defines when the app has been properly initialized. I'd like it to be driven by some nested objects, though. For example, the app is initialized when the account object is initialized, the project object is initialized, etc. My app could then use GlobalAppState.isInitialized, instead of inspected each nested object.
class GlobalAppState: ObservableObject {
#Published var account: Account = Account()
#Published var project: Project = Project()
#Published var isInitialized: Bool {
return self.account.initialized && self.project.initialized;
}
}
I get the error Property wrapper cannot be applied to a computed property
So...clearly, this is currently disallowed. Is there a way I can work around this??? I'd like to be able to use GlobalAppState.initialized as a flag in the app. More to the point, something like GlobalAppState.project.currentProject, which would be a computed property returning the currently selected project, etc...
I can see this pattern being used in a thousand different places! Any help would be wildly appreciated...
Thanks!
In this case there's no reason to use #Published for the isInialized property since it's derived from two other Published properties.
var isInitialized: Bool {
return self.account.initialized && self.project.initialized;
}
Here is one case if both account and project are structures.
struct Account{
var initialized : Bool = false
}
struct Project{
var initialized : Bool = false
}
class GlobalAppState: ObservableObject {
#Published var account: Account = Account()
#Published var project: Project = Project()
#Published var isInitialized: Bool = false
var cancellabel: AnyCancellable?
init(){
cancellabel = Publishers.CombineLatest($account, $project).receive(on: RunLoop.main).map{
return ($0.0.initialized && $0.1.initialized)
}.eraseToAnyPublisher().assign(to: \GlobalAppState.isInitialized, on: self) as AnyCancellable
}
}
struct GlobalAppStateView: View {
#ObservedObject var globalAppState = GlobalAppState()
var body: some View {
Group{
Text(String(globalAppState.isInitialized))
Button(action: { self.globalAppState.account.initialized.toggle()}){ Text("toggle Account init")}
Button(action: { self.globalAppState.project.initialized.toggle()}){Text("toggle Project init")}
}
}
}

Resources