EP 104 · Combine Schedulers · Jun 4, 2020 ·Members

Video #104: Combine Schedulers: Testing Time

smart_display

Loading stream…

Video #104: Combine Schedulers: Testing Time

Episode: Video #104 Date: Jun 4, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep104-combine-schedulers-testing-time

Episode thumbnail

Description

Combine is a powerful framework and is the de facto way to power SwiftUI applications, but how does one test reactive code? We will build a view model from scratch that involves asynchrony and time-based effects and explore what it takes to exhaustively test its functionality.

Video

Cloudflare Stream video ID: ed7d6515905f88a52170b981f074706b Local file: video_104_combine-schedulers-testing-time.mp4 *(download with --video 104)*

References

Transcript

0:05

Today we are beginning a new topic, and it’s a really fun one. It has been nearly one year since Apple announced the Combine framework, which put a reactive framework in the hands of every single developer for Apple’s platforms. This was a huge announcement, because prior to this, adopting a reactive framework meant bringing in a library like RxSwift and ReactiveSwift. Some people thought those dependencies were too heavy to justify their utility, and others thought the reactive paradigm was too complicated and not worth the effort.

0:33

But now that Apple has handed down this framework, and it’s immediately available anytime you are building for Apple’s platforms, there isn’t as much to worry about as there used to be. Even if you do not want reactive code creeping into every part of your application, you can still make use of a bunch of APIs that have been improved using the reactive primitives. For example, there’s no reason to create a URLSession data task from scratch, which you always have to remember to call .resume() on it otherwise it won’t start. Instead you can use the publisher interface to that functionality, and things become quite simpler. Same goes for NSNotificationCenter , which has a nice publisher interface that is much easier to use than the old API.

1:12

So, eventually more and more Combine code will be entering your code base, and we think that’s a good thing. It can really express some really powerful ideas in a small, succinct package. What’s not a good thing is that because Combine is so new it is lacking some crucial features. In particular, asynchronous reactive code is very difficult to test with Combine today. The abstractions are there to make this much nicer, but the actual implementations of those abstractions are still missing.

1:41

And we think this is an appropriate topic for us to cover on Point-Free because one of the big benefits to functional programming is testing. Typically functional code is very easy to test because you can just plug some data into a function and assert on what came out the other side. So, we’d hope that Combine code is also super testable, after all it is a functional reactive framework, but that sadly is not totally true right now.

2:04

So, we are going to fill that gap in the Combine framework, and along the way it is going to give us an excuse to dive deep into an advanced topic of Combine that hasn’t yet gotten much attention in the community. Creating a Combine view model

2:16

To understand the problem we are going to be attacking, let’s take a look at how we might test some Combine code using what Combine gives us out of the box. We are going to do this by building a simple SwiftUI feature using vanilla SwiftUI and vanilla Combine code. Let’s check it out.

2:32

We have a simple register form mocked out in SwiftUI right now. It just has an email field, a password field and a register button: struct ContentView: View { var body: some View { NavigationView { Form { Section(header: Text("Email")) { TextField( " [email protected] ", text: .constant("") ) } Section(header: Text("Password")) { TextField( "Password", text: .constant("") ) } Button("Register") { } } .navigationBarTitle("Register") } } }

2:40

When the register button is tapped we want to send the email and password to an API endpoint, which will then return a response that tells us whether the registration was successful.

2:50

We are going to implement this screen’s logic using plain Combine and SwiftUI concepts because we want to concentrate on how to test Combine code. Sure we love the Composable Architecture, but there are going to be far more people out there writing vanilla Combine code than Composable Architecture code, and so we want to make sure everyone uses these tools to the best of their ability.

3:10

The standard way to approach this is to cook up a class that conforms to the ObservableObject protocol. This allows gives you a place to put some application logic that is persisted across multiple UI renders, and is the natural place to perform API requests. Such classes are also typically called “view models”, and so let’s create a stub of a registration view model: class RegisterViewModel: ObservableObject { }

3:35

And then we hold onto an instance of this view model in our view: struct ContentView: View { @ObservedObject var viewModel = RegisterViewModel() }

3:47

The view model can hold onto all the data our view represents, such as the email and password fields: @Published var email = "" @Published var password = ""

4:02

They are marked as @Published so that under the hood their value is powered by a Combine publisher. This allows the view to be re-rendered anytime their value changes, and allows us to get access to their stream of values over time as a publisher, which will be useful in a moment.

4:17

Already we can hook up these fields with the view because we can derive bindings from the fields, which is what our TextField s need in order to communicate back and forth with the view model: TextField(" [email protected] ", text: self.$viewModel.email) … TextField("Password", text: self.$viewModel.password)

5:02

Now every key stroke will be fed into the view model so that we can react to it, and every change we make to the view model will similarly be fed back to the view to re-render.

5:12

Let’s start to hook up some logic. We’ll create an endpoint in our view model that is called when the register button is tapped: func registerButtonTapped() { } … Button("Register") { self.viewModel.registerButtonTapped() }

5:28

And this is where we can do our registration logic.

5:32

Let’s introduce a function that simulates a network request for registration: func registerRequest( email: String, password: String ) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { var components = URLComponents( string: "https://www.pointfree.co/register" )! components.queryItems = [ URLQueryItem(name: "email", value: email), URLQueryItem(name: "password", value: password) ] return URLSession.shared .dataTaskPublisher(for: components.url!) .eraseToAnyPublisher() }

5:47

It constructs and returns a data task publisher using the email and string it’s passed as URL query items. Now we don’t actually have this endpoint on pointfree.co, but let’s just pretend for now.

6:25

And then in registerButtonTapped we want to fire off that request, which should drive this state. func registerButtonTapped() { registerRequest(email: self.email, password: self.password) }

6:38

We would like to use this function in our view model to determine whether or not a user has successfully registered. We can add more state to our view model to capture this, maybe simply as a boolean: @Published var isRegistered = false

6:59

To keep things super simple we will assume that the server sends back a string that literally says “true” or “false” to determine if the registration was successful. We can also replace any publisher errors, such as if we didn’t have an internet connection, with a false value to ultimately derive a publisher of boolean values that determines if we successfully registered, which we can assign to our view model state with a sink. func registerButtonTapped() { registerRequest(email: self.email, password: self.password) .map { data, _ in Bool(String(decoding: data, as: UTF8.self)) ?? false } .replaceError(with: false) .sink { self.isRegistered = $0 } }

9:24

Now it’s accessible in our view, but we are getting a warning about not using the return value of sink . This is a good error because sink returns a cancellable, and if we don’t hold onto it, it will be deallocated immediately, which will stop our request. So, let’s hold onto it: var cancellables: Set<AnyCancellable> = [] … registerRequest(email: self.email, password: self.password) .sink { self.isRegistered = $0 } .store(in: &self.cancellables)

10:03

And now in our view we can react to this value changing. if self.viewModel.isRegistered { Text("Welcome!") } else { … }

10:27

And now technically we could give this a spin in the SwiftUI preview or simulator, but of course it won’t work because we are hitting an endpoint that doesn’t exist. And really, depending on a real life registration endpoint is not a nice way to develop the feature. Even if we had a good endpoint to hit we would be literally registering users on our server as we test. That seems pretty wasteful.

10:58

To do this we will have our view model take a dependency that has to be supplied by whoever is constructing the view model. It will be a function that takes the email and password, and returns a publisher, which can be passed into the initializer: let register: (String, String) -> AnyPublisher< (data: Data, response: URLResponse), URLError > … init( register: @escaping (String, String) -> AnyPublisher< (data: Data, response: URLResponse), URLError > ) { self.register = register }

11:49

And then we can replace our live publisher with something that is controlled from the outside: // registerRequest(email: self.email, password: self.password) self.register(self.email, self.password)

12:22

And now we need to fix our view so that this register endpoint is explicitly passed into the view. Currently we are constructing the view model right in the view, but perhaps better would have it passed in by whoever creates the view: @ObservedObject var viewModel: RegisterViewModel

12:45

Now we get to use a mocked endpoint when we create the view for our previews: ContentView( viewModel: RegisterViewModel( register: { _, _ in Just((Data("true".utf8), URLResponse()) .setFailureType(to: URLError.self) .eraseToAnyPublisher() }) )

14:07

And we get to use the “live” network request for our application in the scene delegate: let contentView = ContentView( viewModel: RegisterViewModel( register: registerRequest(email:password:) ) )

14:35

We can now run the app in our SwiftUI preview to see that as soon as we tap register in the screen switches to say welcome.

15:04

And if we simulate a failing request: Just((Data("false".utf8), URLResponse())

15:06

We do not go to a registered state.

15:18

So that, in a nutshell, is how you build a vanilla Combine and SwiftUI app. You create a class that conforms to ObservableObject , you add all your state to it as @Published properties, and you execute all your business logic and side effects inside so that your views can subscribe to state changes.

15:44

Ergonomic interlude I

15:44

So while it appears our form is basically working, its user experience is a bit wonky, so let’s make some changes.

15:56

When registration fails there is no indication in the UI. It’d be nice to present an alert to the end user when this happens.

16:09

But we have another problem. We can add a little delay to our mocked publisher to simulate time it would take to make a network request: Just((Data("true".utf8), URLResponse()) .setFailureType(to: URLError.self) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToAnyPublisher() }

16:22

Now when we run the preview we see there’s a delay before getting the welcome message. The user experience for this isn’t so great though. Even worse, if there was an error, like if we return false from the mock instead of true , there’s nothing in the UI to show us this.

16:35

Let’s fix a few of these annoying problems with the app. We will introduce a new piece of state to our view model that determines whether or not the register request is inflight, which will give us the opportunity to show a message while it’s loading: @Published var isRegisterRequestInFlight = false … func registerButtonTapped() { self.isRegisterRequestInFlight = true self.register(self.email, self.password) .map { data, _ in Bool(String(decoding: data, as: UTF8.self)) ?? false } .replaceError(with: false) .sink { self.isRegistered = $0 self.isRegisterRequestInFlight = false } ) .store(in: &self.cancellables) } … HStack { if self.viewModel.isRegisterRequestInFlight { Text("Registering...") } else { Button("Register") { self.viewModel.registerButtonTapped() } } }

17:29

And now when we run our preview, it’s much more clear to the user that a request is in-flight.

17:41

In case of failure, we’ll also add a piece of state that determines if an error alert should be shown: struct Alert: Identifiable { var title: String var id: String { self.title } } … @Published var errorAlert: Alert? … func registerButtonTapped() { self.isRegisterRequestInFlight = true self.register(self.email, self.password) .map { data, _ in Bool(String(decoding: data, as: UTF8.self)) ?? false } .replaceError(with: false) .sink { self.isRegistered = $0 self.isRegisterRequestInFlight = false if !$0 { self.errorAlert = .init( title: "Failed to register. Please try again." ) } } ) .store(in: &self.cancellables) } … .alert(item: self.$viewModel.errorAlert) { errorAlert in Alert(title: Text(errorAlert.title)) }

19:33

And with just that little bit of work our app is looking a lot better. We now show an indicator that we are in the process of trying to register you, and we show an error if something goes wrong. Testing a Combine view model

19:43

Testing the view model as we have designed it is pretty straightforward, thanks to the fact that we explicitly pass along the register endpoint.

20:16

We can simply construct a view model with a mock version of that endpoint, like say one that simply returns true immediately for testing a successful registration: func testRegistrationSuccessful() { let viewModel = RegisterViewModel( register: { _, _ in Just((Data("true".utf8), URLResponse())) .setFailureType(to: URLError.self) .eraseToAnyPublisher() } ) }

20:51

Then we can start writing some assertions. For example, we could simulate the flow of the user entering their email and password and then pressing the register button: viewModel.email = " [email protected] " viewModel.password = "blob is awesome" viewModel.registerButtonTapped()

21:25

And because our mock endpoint says that our registration was successful, we should be able to assert on the view model’s isRegistered property to make sure it flipped to true : XCTAssertEqual(viewModel.isRegistered, true)

21:38

And this test passes! That was pretty easy.

21:40

If we want to make things a little bit stronger we can make assertions beforehand that isRegistered started false . XCTAssertEqual(viewModel.isRegistered, false) viewModel.email = " [email protected] " viewModel.password = "blob is awesome" viewModel.registerButtonTapped() XCTAssertEqual(viewModel.isRegistered, true)

21:49

That was easy. Now let’s test the unhappy path. We can copy and paste our existing test with a few changes. func testRegistrationFailure() { let viewModel = RegisterViewModel( register: { _, _ in Just((Data("false".utf8), URLResponse())) .setFailureType(to: URLError.self) .eraseToAnyPublisher() } ) XCTAssertEqual(viewModel.isRegistered, false) viewModel.email = " [email protected] " viewModel.password = "blob is awesome" viewModel.registerButtonTapped() XCTAssertEqual(viewModel.isRegistered, false) XCTAssertEqual( viewModel.errorAlert?.title, "Failed to register. Please try again." ) }

22:31

And this test passes!

22:43

These tests were easy to write but not nearly as strong as they could be. Right now we are asserting only on what value the view model ended with, but it could have emitted other stuff too along the way and it would be nice to capture this in a test.

22:58

To fix this we will subscribe to the publisher that powers the isRegistered field of our view model, and append all of its emissions to an array. Then we can assert against the full history of the publisher rather than just the most recent emission: var cancellables: Set<AnyCancellable> = [] … var isRegistered: [Bool] = [] viewModel.$isRegistered .sink { isRegistered.append($0) } .store(in: &self.cancellables) XCTAssertEqual(isRegistered, [false]) viewModel.email = " [email protected] " viewModel.password = "blob is awesome" viewModel.registerButtonTapped() XCTAssertEqual(isRegistered, [false, true])

24:50

And this passes!

24:52

We can even put an assertion before we interact with the view model and between each interaction to make sure that state only changes when we expect it to: XCTAssertEqual(isRegistered, [false]) viewModel.email = " [email protected] " XCTAssertEqual(isRegistered, [false]) viewModel.password = "blob is awesome" XCTAssertEqual(isRegistered, [false]) viewModel.registerButtonTapped() XCTAssertEqual(isRegistered, [false, true])

25:11

And this is how to test Combine publishers in a nutshell. You’ll create a little mutable array to hold all the emissions from the publisher, subscribe to the publisher to append the values to the array, and then make assertions against that array. There’s a decent amount of boilerplate necessary to test this, but it’s also possible to cook up helpers that hide all of that and make things quite ergonomic. Other reactive libraries such as RxSwift and ReactiveSwift have nice helpers that you might be able to get some inspiration from. A problem with scheduling

25:43

But instead of doing that we want to push our testing further, because right now there’s technically a problem with our registration code. We can’t see it in our SwiftUI preview because we are using a mocked, synchronous publisher, but if we run in the simulator and try to register we will see the following logs: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

26:21

You are not allowed to make changes to an observable object on background threads because that means SwiftUI will try to update the UI on a background thread. The fix is to make sure that our registration publisher emits its values on the main queue: self.register(self.email, self.password) .receive(on: DispatchQueue.main) …

27:20

And now when we run in the simulator we don’t get that warning, but if we run tests we get failures: XCTAssertEqual failed: (”[false]”) is not equal to (”[false, true]”) XCTAssertEqual failed: (“nil”) is not equal to (”"Failed to register. Please try again.”")

27:35

This is happening because we have actually introduced something quite complex into our publisher chain. The reference to DispatchQueue.main is involving all the machinery of grand central dispatch and asynchrony, which completely destroys our ability to test this code in a straightforward manner.

28:04

To restore our passing tests we must wait for a small amount of time before we assert: viewModel.registerButtonTapped() _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) XCTAssertEqual(isRegistered, [false, true])

28:31

And now it passes.

28:52

So, it’s pretty cool that tests pass, but this is a pretty wonky way to do things. Right now we are waiting a pretty minuscule amount of time, but it’s completely arbitrary that we chose ten milliseconds. Maybe it can be made much smaller, like a millisecond or microsecond, but at some point we may choose too small of a time measurement so that it doesn’t give the run loop enough time to do a full tick.

29:27

The point here is that we are depending on something that is not very precise. We are choosing a small value and hoping that it is small enough to not slow down our tests and long enough to get the job done. It would be much better if we had a more scientific way of controlling time. Adding a more complex feature

29:45

But even though it’s a bit wonky, let’s keep pushing this view model a little further. I’d like to add a new feature that shows a message in the UI telling the user whether or not their password is strong enough. Let’s assume we have very specific requirements for the password to register, and we want to improve the UX of the form by letting the user know when their password is valid and when it is not.

30:09

To accomplish this we will add more state to our view model: @Published var passwordValidationMessage = ""

30:19

And render it in our view: TextField("Password", text: self.$viewModel.password) if !self.viewModel.passwordValidationMessage.isEmpty { Text(self.viewModel.passwordValidationMessage) .padding([.leading]) }

30:39

Then each time the password field changes we want to update this field with a validation message.

30:44

This logic could be quite complicated. What if it did an analysis of the password to look for traits of weak passwords, like commonly used phrases, or required number of non-alphanumeric characters. Then we probably wouldn’t want to embed that logic directly into the client, because not only is it significant but it could also change often. So instead, we would want to put that logic on our server and have the clients hit an endpoint to request a validation message.

31:12

To do this, we will take advantage of Combine to invoke an API request each time the password field is changed so that we can get a validation message from the server. We can represent the API request as a function that takes a string, which is the password, and returns a publisher of a string, which represents the validation message we load from the server.

31:21

Just like for the register endpoint, we will pass this new validation endpoint directly into the initializer of the view model. init( register: @escaping (String, String) -> AnyPublisher< (data: Data, response: URLResponse), URLError >, validatePassword: @escaping (String) -> AnyPublisher< (data: Data, response: URLResponse), URLError > ) { self.register = register }

31:32

As with register , validatePassword will return a data task publisher with a response containing the validation message meant to be shown to our users.

31:42

Passing dependencies this way is a good best practice for handling dependencies because it allows you to use a live request for running your app in the simulator or device, but allows you to use a mocked version when running in SwiftUI previews or when testing.

31:55

With this change we will need to fix the call sites that invoke this initializer. One is in our SwiftUI preview, for which we can introduce a mock endpoint with some basic validation logic: func mockValidate( password: String ) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { let message = password.count < 5 ? "Password is too short 👎" : password.count > 20 ? "Password is too long 👎" : "Password is good 👍" return Just((Data(message.utf8), URLResponse())) .setFailureType(to: URLError.self) .eraseToAnyPublisher() }

32:32

And hand it along to the preview: struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( viewModel: RegisterViewModel( register: { _, _ in Just((Data("false".utf8), URLResponse())) .setFailureType(to: URLError.self) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToAnyPublisher() }, validatePassword: mockValidate(password:) ) ) } }

32:40

And we need to update the scene delegate. We will also use the mock for this endpoint because we don’t actually have a URL we can hit, but whenever we get that information we would update this part of our code: validatePassword: mockValidate(password:)

32:54

So now that our view model has access to this endpoint, how do we use it? We want every change to the password field to trigger an API request so that we can validate that password. We can do this using Combine because underlying the password field is a publisher since we are using the @Published properly wrapper. We can get access to this publisher using the special $ syntax: self.$password

33:09

This is a publisher of passwords, and from it we want a publisher of validation messages. To do this we can turn to an operator that we have covered in-depth on Point-Free. A year and a half ago we embarked on a 5-part series covering flatMap from first principles ( part 1 , part 2 , part 3 , part 4 , part 5 ). We showed that not only does the Swift standard library ship with quite a few flatMap s, including on arrays, optionals and results, and even in the extended Apple ecosystem there are more, but more importantly we showed that flatMap expresses the essence of what it means to sequence computations.

33:43

And if we thinking about it, sequencing computations is exactly what we want to do here. Every time a key stroke happens in the password field we want to then fire off an API request to get the validation message for that password. So we wait for a key stroke to happen, and then we perform an API request. That is the sequencing of two events.

34:02

And so what we want to do is flatMap the password publisher so that we can execute the validatePassword publisher with each emission. That looks like this: self.$password .flatMap { password in validatePassword(password) .map { data, _ in String(decoding: data, as: UTF8.self) } .replaceError(with: "Could not validate password.") } }

34:54

Then we can sink on this publisher so that each emission we feed into the passwordValidationMessage field, which will then cause the UI to update: self.$password .flatMap { password in validatePassword(password) } .sink { self.passwordValidationMessage = $0 }

35:20

And finally we need to store the cancellable this returns in our set of cancellables: self.$password .flatMap { password in validatePassword(password) } .sink { self.passwordValidationMessage = $0 } .store(in: &self.cancellables)

35:29

We should also weakify self in our sink to prevent a retain cycle: self.$password .flatMap { password in validatePassword(password) } .sink { [weak self] in self?.passwordValidationMessage = $0 } .store(in: &self.cancellables)

36:09

Ergonomic interlude II

36:09

That is the basics of how we can implement this functionality. In fact, if we run the app in the SwiftUI preview it seems to operate basically the same as before. Let’s try running tests.

36:48

Our registration tests are not compiling right now because when we create a view model it is not being handed the new validation endpoint. These tests aren’t asserting against the result of the validation request, so we can just mock in an empty publisher: validatePassword: { _ in Empty(completeImmediately: true).eraseToAnyPublisher() } … validatePassword: { _ in Empty(completeImmediately: true).eraseToAnyPublisher() }

37:30

Now things are building again and we can write a test for validation. We can start by instantiating a view model. func testValidatePassword() { let viewModel = RegisterViewModel( register: <#(String, String) -> AnyPublisher<(Data, URLResponse), URLError>#>, validatePassword: <#(String) -> AnyPublisher<(Data, URLResponse), URLError>#> ) }

37:50

For the register endpoint we can fatal error since we won’t be exercising this functionality in this test. register: { _, _ in fatalError() },

38:02

But what about validatePassword ? validatePassword: <#???#>

38:05

Now it’s important to note that we aren’t wanting to test what happens inside the validation endpoint. Remember that logic is on the server and is therefor out of our control. We extracted the external dependency specifically because we did not want to know about what the endpoint was doing.

38:20

However, what we do want to test is that the endpoint is being invoked properly and that its data is being fed back into our view model properly. Such a test is showing that as long as the endpoint is behaving well on the server, our view model should also be well behaved, which should also mean the UI is behaving.

38:36

So one way to write this test is to reuse the bit of mock validation we defined earlier: let viewModel = RegisterViewModel( register: { _, _ in fatalError() }, validatePassword: mockValidate(password:) )

38:48

And then we can assert that it behaves as we expect it to. var passwordValidationMessage: [String] = [] viewModel.$passwordValidationMessage .sink { passwordValidationMessage = $0 }

29:28

With this infrastructure in place we can now write some assertions against the view model as we interact with it. In particular, when we first reach this screen we expect the validation message to be empty. XCTAssertEqual(passwordValidationMessage, [""])

39:54

However, this fails: XCTAssertEqual failed: ([“Password is too short 👎”]) is not equal to ([””])

40:00

An easy fix for this is to not validate the password when it’s an empty string: self.$password .flatMap { password in password.isEmpty ? Just("").eraseToAnyPublisher() : validatePassword(password) .map { data, _ in String(decoding: data, as: UTF8.self) } .replaceError(with: "Could not validate password.") .eraseToAnyPublisher() }

40:47

And now the passes! This is also demonstrating that tests really are capturing view model specific logic and are not meant to capture the logic inside the validatePassword function.

41:01

Now we can play a whole script of the user updating the password field to make sure that the validation message is rendering what we expect. viewModel.password = "blob" XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", ]) viewModel.password = "blob is awesome" XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", "Password is good 👍", ]) viewModel.password = "blob is awesome!!!!!!" XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", "Password is good 👍", "Password is too long 👎", ])

42:07

Tests pass and if we resume our Xcode preview we can see that as the password changes, the validation message changes in kind.

42:31

But the experience doesn’t feel very real right now. These mock validations should come from a real server in production, so it’d be nice for them to be delayed a bit to simulate this latency. validatePassword: { mockValidate(password: $0) .delay(for: 0.5, on: DispatchQueue.main) .eraseToAnyPublisher() }

43:00

And when we run the preview, it’s behaving a little more realistically. More scheduling

43:19

Our view model isn’t quite right because as we’ve seen before we need to explicitly do .receive(on: DispatchQueue.main) if we expect a publisher to emit on a non-main queue, so let’s do that: self.$password .flatMap { password in password.isEmpty ? Just("").eraseToAnyPublisher() : validatePassword(password) .receive(on: DispatchQueue.main) .map { data, _ in String(decoding: data, as: UTF8.self) } .replaceError(with: "Could not validate password.") .eraseToAnyPublisher() }

43:54

But now when we run tests we get 3 failures. Each of these failures is due to the fact that we have done something quite complex with our publishers by introducing asynchrony, in particular the .receive(on:) operator.

44:03

Just like last time we can fix this by introducing a little bit of waiting: viewModel.password = "blob" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", ]) viewModel.password = "blob is awesome" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", "Password is good 👍", ]) viewModel.password = "blob is awesome!!!!!!" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) XCTAssertEqual(passwordValidationMessage, [ "", "Password is too short 👎", "Password is good 👍", "Password is too long 👎", ])

44:15

Tests are passing, and things are looking good. All of the waits littered throughout the test seems like a hack, but at least it’s working.

44:21

Well, that is until we add even more asynchrony to this view model. More complexity

44:25

There’s something in our validationMessage function that we want to improve. Right now we are making an API request every time the password is changed. This means if I quickly type out a 10 character password, 10 requests will be made to our server, and 9 of those requests will have information that is out of date and of no interest to us. There are two problems with this:

44:52

We are performing way more requests than necessary. If I type faster than the server can respond, then I will be needlessly performing lots of requests that did not need to be performed.

45:03

Further, all of the requests fired off could be coming back in a non-deterministic order. What if the request performed for the first key stroke took a very long time, for whatever reason, and it completed after we were done typing our password. Then unless we are careful we may accidentally show that validation message even though it is no longer pertinent.

45:21

A better behavior would be for us to require that key strokes must stop for a brief amount of time before firing off an API request. Further, if an API request is fired off and we start typing again, then that in-flight request should be canceled and its response should not be emitted.

45:37

Luckily Combine comes with an operator that packages all of this up into one powerful operator, and it’s called debounce . However, not so lucky for us, using this operator makes testing a little bit harder.

45:50

To use the operator we just need to tack it onto our password publisher so that only emissions that are spaced out by 300 milliseconds or more are let through: self.$password .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)

46:08

If we run tests we get 3 failures, and to fix them we just gotta increase our waits to make up for the fact that we are debouncing the password field: _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.31) … _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.31) … _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.31)

46:32

We could even get a little fancy with our test and make it prove that the password changes are indeed being debounced by waiting only 0.21 seconds after one password change before making another change: viewModel.password = "blob is awesome" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.21) XCTAssertEqual( expectedOutput, [ "", "Password is too short 👎", // "Password is good 👍", ] ) viewModel.password = "blob is awesome!!!!!!" _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.31) XCTAssertEqual( expectedOutput, [ "", "Password is too short 👎", // "Password is good 👍", "Password is too long 👎", ] )

47:06

This is showing that we did not get the intermediate validation message saying that the password is good because we typed too quickly to get another password that was invalid.

47:14

So we are indeed testing a quite complex view model, and also showing how one can test asynchronous and time based publishers: you simply wait some time to pass so that the publisher can do its job, and then you make assertions.

47:27

But one thing to note about this test suite is how long it takes to execute: Executed 1 test, with 0 failures (0 unexpected) in 0.934 (0.936) seconds

47:33

It takes 0.936 seconds to run, but we are waiting for 0.93 seconds. So this test would be nearly instant, but 99% of the time is spent just waiting for the dispatch queue to do its work. This seems super wasteful, and as we write dozens or hundreds of these tests the time could really add up to minutes of wasted time. Heck, maybe we even want to test some code that literally waits minutes or hours in production. We certainly don’t want to wait that time in tests!

48:01

Even worse, this form of waiting is super fragile. For example, right now we are waiting 0.31 seconds, but that’s only because first we were waiting 0.01 seconds to get past the receive(on:) and then we just added 0.3 seconds because that’s how long we are debouncing for. But can we just wait 0.3 seconds now since we are using a proper time-based operator? _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.3)

48:27

Well that made our tests fail.

48:31

Maybe we don’t need to wait 0.31 seconds, maybe we can get away from 0.301 seconds: _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.301)

48:37

If we run this a few times we will see things pass, but this is not guaranteed. So even worse than making the test suite take longer than necessary to run, I just don’t have a ton of confidence that this won’t intermittently fail sometime in the future. We’re fudging the waiting times just to get it passing, but maybe some day in the future this won’t be enough and we’ll get random, unexplained failures. This could happen, for example, on slower CI machines.

48:53

This just doesn’t seem to be very dependable. I wouldn’t want to depend on this test suite where first there’s magical constants being used to determine how long to wait, and second the test suite could randomly fail for no fault of our own. This creates an environment of not trusting our test suite. Next time: controlling time

49:07

Luckily Combine gives us a tool for handling this. The crux of the problem is that we used DispatchQueue.main when telling our publishers what queue to receive values on and when describing how to debounce values.

49:21

There is another value we can use instead of DispatchQueue.main which executes its work immediately, rather than invoking the full machinery of Grand Central Dispatch. Let’s give it a spin…next time! References combine-schedulers Brandon Williams & Stephen Celis • Jun 14, 2020 An open source library that provides schedulers for making Combine more testable and more versatile. http://github.com/pointfreeco/combine-schedulers Downloads Sample code 0104-combine-schedulers-pt1 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .