4 Episodes · 2h01min
- View Models 33:58
- Deep Linking 27:12
- Playground-Driven Development 21:24
- Test-Driven Reactive Programming 38:47
Swift Talk # 53
with special guest Lisa Luo
Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber →
Lisa from Kickstarter shows us their test-driven development process to reactive programming.
00:06 We're joined by Lisa from Kickstarter today. Together we're going to use test-driven reactive programming to implement the logic of a signup form. A view model will hold all of the logic.
00:31 We'll first take a look at the plain view controller we've
prepared. It has four UI components: three text fields and a submit button.
We've added a target to each component inside viewDidLoad
. Later in this
episode, we'll implement the methods called by the components:
class MyViewController: UIViewController {
let emailTextField = UITextField()
let nameTextField = UITextField()
let passwordTextField = UITextField()
let submitButton = UIButton()
override func viewDidLoad() {
super.viewDidLoad()
self.emailTextField.addTarget(self,
action: #selector(emailChanged),
for: .editingChanged)
self.nameTextField.addTarget(self,
action: #selector(nameChanged),
for: .editingChanged)
self.passwordTextField.addTarget(self,
action: #selector(passwordChanged),
for: .editingChanged)
self.submitButton.addTarget(self,
action: #selector(submitButtonPressed),
for: .touchUpInside)
}
func submitButtonPressed() {
}
func emailChanged() {
}
func nameChanged() {
}
func passwordChanged() {
}
// ...
}
01:03 The view model we use conforms to three protocols. Kickstarter
organizes its code with view models that have protocols for their inputs
and
their outputs
and a ViewModelType
that provides the interface to the inputs
and outputs:
protocol MyViewModelInputs {
}
protocol MyViewModelOutputs {
}
protocol MyViewModelType {
var inputs: MyViewModelInputs { get }
var outputs: MyViewModelOutputs { get }
}
class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
init() {
}
var inputs: MyViewModelInputs { return self }
var outputs: MyViewModelOutputs { return self }
}
01:30 When we first looked at Kickstarter's actual codebase, this pattern seemed overly complicated, but soon we saw the same architecture can be used everywhere. Once you're familiar with this separation, the code becomes very easy to read and everything starts to make sense.
01:48 Before we start implementing the logic, we need to define the rules the submit form should follow:
The submit button is only enabled when all of the form fields are present — i.e. they contain text.
The signup is successful if the email address is valid.
As a security measure, the submit button is disabled forever after three unsuccessful signup attempts.
02:56 We have four UI components that accept input, so it makes sense to
base the view model's inputs on these components. Text fields can be empty, so
we use optional strings for the input values of the three text field components.
There's also a method for when the submit button is pressed. We define a fifth
input that describes the initial state of the view, which is called from
viewDidLoad
— that way, the view model ties in with the view controller life
cycle:
protocol MyViewModelInputs {
func nameChanged(name: String?)
func emailChanged(email: String?)
func passwordChanged(password: String?)
func submitButtonPressed()
func viewDidLoad()
}
04:13 Now we have to implement these methods in the view model class.
04:27 The Kickstarter team uses MutableProperty
to bridge between the
imperative and the functional world. This allows the members to set a property's
value through a method from the view controller and to use the value reactively
as a signal. MutableProperty
is generic over its underlying type and the
initializer takes an initial value. We follow Kickstarter's naming convention:
let nameChangedProperty = MutableProperty<String?>(nil)
In the view model's nameChanged
method, we pass the new value to the above
property. The other three text fields use the same pattern:
class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
// ...
let nameChangedProperty = MutableProperty<String?>(nil)
func nameChanged(name: String?) {
self.nameChangedProperty.value = name
}
let emailChangedProperty = MutableProperty<String?>(nil)
func emailChanged(email: String?) {
self.emailChangedProperty.value = email
}
let passwordChangedProperty = MutableProperty<String?>(nil)
func passwordChanged(password: String?) {
self.passwordChangedProperty.value = password
}
}
05:34 The other two inputs — submitButtonPressed
and viewDidLoad
—
don't have a value to store. We still "ping" the property by passing in ()
, a
void value:
class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
// ...
let submitButtonPressedProperty = MutableProperty()
func submitButtonPressed() {
self.submitButtonPressedProperty.value = ()
}
let viewDidLoadProperty = MutableProperty()
func viewDidLoad() {
self.viewDidLoadProperty.value = ()
}
}
05:58 The next step is to think of our app's outputs, such as displaying an alert message to the user upon submitting the form. Another output is whether or not the submit button is enabled.
06:48 In the outputs protocol, we specify two signals, which are generic
over both their value's type and an error type, NoError
:
protocol MyViewModelOutputs {
var alertMessage: Signal<String, NoError> { get }
var submitButtonEnabled: Signal<Bool, NoError> { get }
}
07:38 We then implement these signals in the view model, declared with
let
, because they can be immutable. We'll properly define the signals later,
but we can start by instantiating them as empty signals:
class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
init() {
self.alertMessage = .empty
self.submitButtonEnabled = .empty
}
// ...
let alertMessage: Signal<String, NoError>
let submitButtonEnabled: Signal<Bool, NoError>
}
08:45 We've defined the inputs and outputs. But before connecting the two with functionality, we'll write the tests.
09:06 These tests will model the rules we defined for the interface's behavior. We'll cover all edge cases with our tests and then work on our implementation until all tests succeed.
09:36 In our test suite, we create a
TestObserver
for each output — this is a class, written by Kickstarter, that gives access to
the history of a property's values:
class ViewModelTests: XCTestCase {
let vm: MyViewModelType = MyViewModel()
let alertMessage = TestObserver<String, NoError>()
let submitButtonEnabled = TestObserver<Bool, NoError>()
}
10:53 We hook up the view model outputs to the corresponding observers:
class ViewModelTests: XCTestCase {
let vm: MyViewModelType = MyViewModel()
let alertMessage = TestObserver<String, NoError>()
let submitButtonEnabled = TestObserver<Bool, NoError>()
override func setUp() {
super.setUp()
self.vm.outputs.alertMessage.observe(self.alertMessage.observer)
self.vm.outputs.submitButtonEnabled.observe(self.submitButtonEnabled.observer)
}
}
11:13 We're now ready to write some tests. We walk through possible
scenarios and check the history of an output's values to see whether our rules
are being followed. We start with testing the submitButtonEnabled
state. In
this test, we first call viewDidLoad
, which sets up the view. Then we check
that the submit button is disabled, following the rule that the form is only
valid if all fields contain a value:
func testSubmitButtonEnabled() {
self.vm.inputs.viewDidLoad()
self.submitButtonEnabled.assertValues([false])
}
12:19 We assert the property's values as an array instead of a single value because we're checking the entire history of the property. As we go through the scenario, the history is collected by the observer.
13:15 Next, we simulate the user entering their name in the name text field. The submit button should still be disabled, because not all fields are valid yet:
func testSubmitButtonEnabled() {
self.vm.inputs.viewDidLoad()
self.submitButtonEnabled.assertValues([false])
self.vm.inputs.nameChanged(name: "Chris")
self.submitButtonEnabled.assertValues([false])
}
13:35 We then enter an email address and a password. After entering the
password, submitButtonEnabled
should go from false
to true
. We check the
property's history, which should now have a second boolean, true
:
func testSubmitButtonEnabled() {
// ...
self.vm.inputs.emailChanged(email: "chris@gmail.com")
self.submitButtonEnabled.assertValues([false])
self.vm.inputs.passwordChanged(password: "secret123")
self.submitButtonEnabled.assertValues([false, true])
}
14:45 By testing the history of a property, we not only check that the value changed, but we also check all the changes the property went through. When we clear the name field, the submit button should go back to being disabled:
func testSubmitButtonEnabled() {
// ...
self.vm.inputs.nameChanged(name: "")
self.submitButtonEnabled.assertValues([false, true, false])
}
15:42 We write a second test for a successful signup. After filling out the form and pressing the submit button, we check the alert message property:
func testSuccessfulSignup() {
self.vm.inputs.viewDidLoad()
self.vm.inputs.nameChanged(name: "Lisa")
self.vm.inputs.emailChanged(email: "lisa@rules.com")
self.vm.inputs.passwordChanged(password: "password123")
self.vm.inputs.submitButtonPressed()
self.alertMessage.assertValues(["Successful"])
}
17:02 We can copy this test and adjust it slightly in order to test how an invalid email address is handled:
func testUnsuccessfulSignup() {
self.vm.inputs.viewDidLoad()
self.vm.inputs.nameChanged(name: "Lisa")
self.vm.inputs.emailChanged(email: "lisa@rules")
self.vm.inputs.passwordChanged(password: "password123")
self.vm.inputs.submitButtonPressed()
self.alertMessage.assertValues(["Unsuccessful"])
}
17:24 We also test the last rule defined for our form: after three unsuccessful attempts, the form should disable itself. We enter an invalid email address and hit the submit button three times, after which we check the alert message's history:
func testTooManyAttempts() {
self.vm.inputs.viewDidLoad()
self.vm.inputs.nameChanged(name: "Lisa")
self.vm.inputs.emailChanged(email: "lisa@rules")
self.vm.inputs.passwordChanged(password: "password123")
self.vm.inputs.submitButtonPressed()
self.vm.inputs.submitButtonPressed()
self.vm.inputs.submitButtonPressed()
self.alertMessage.assertValues(["Unsuccessful", "Unsuccessful", "Too Many Attempts"])
}
18:27 We also check the submit button's enabled state in this scenario. The button should start out disabled when the view loads, become enabled when all the fields are filled out, and finally become disabled after the third submit attempt:
func testTooManyAttempts() {
// ...
self.submitButtonEnabled.assertValues([false, true, false])
}
18:43 To strongly test our security rule, we make sure the button stays disabled, even after we fix the invalid email address:
func testTooManyAttempts() {
// ...
self.vm.inputs.emailChanged(email: "lisa@rules.com")
self.submitButtonEnabled.assertValues([false, true, false])
}
19:13 We're ready to write the logic and make our tests pass.
19:28 Back in the view model's initializer, we can make our outputs stay expressive and concise by creating some helper signals. For instance, we know our form data consists of three fields — name, email, and password — so we can combine these three signals into one signal:
class MyViewModel: MyViewModelType, MyViewModelInputs, MyViewModelOutputs {
init() {
let formData = Signal.combineLatest(
self.emailChangedProperty.signal,
self.nameChangedProperty.signal,
self.passwordChangedProperty.signal
)
// ...
}
// ...
}
20:17 This formData
is a new signal that combines the signals from
our three properties. Therefore, the type of this signal is a tuple of three
String?
s, along with the NoError
.
20:33 Using formData
, we can create a signal for the successful
signup message. We can sample the formData
signal when the signal of
submitButtonPressedProperty
fires — in other words, each time the submit
button is pressed, this new signal emits the latest value of the form data:
let successfulSignupMessage = formData
.sample(on: self.submitButtonPressedProperty.signal)
21:14 Since the signal is meant for the successful signup message, we filter using a helper that checks if all form data is valid:
let successfulSignupMessage = formData
.sample(on: self.submitButtonPressedProperty.signal)
.filter(isValid(email:name:password:))
21:35 The filter line uses syntax called point-free style, which comes from functional programming. This allows us to compose functions without having to explicitly pass in the values — or points.
22:06 Finally, we have to map the boolean we receive to a message:
let successfulSignupMessage = formData
.sample(on: self.submitButtonPressedProperty.signal)
.filter(isValid(email:name:password:))
.map { _ in "Successful" }
22:24 If we assign this signal to alertMessage
, we can check that a
first test, testSuccessfulSignup
, passes.
22:46 It's easy to make testUnsuccessfulSignup
pass as well. We
create a signal for the unsuccessful signup by again sampling the formData
helper signal, this time filtering invalid form data.
23:12 We take another in-between step and create a signal for when the submit button is pressed with invalid form data. From this we can derive both an unsuccessful message signal and a signal for too many signup attempts:
let submittedFormDataInvalid = formData
.sample(on: self.submitButtonPressedProperty.signal)
.filter { !isValid(email: $0, name: $1, password: $2) }
23:55 Because we're performing a negation of isValid
in a closure, we
unfortunately can't use the same point-free style, so we manually pass in the
values from the form data tuple.
24:13 This new signal pings when the user enters invalid form data and submits it. We then map over the signal to create a signal for the unsuccessful message:
let unsuccessfulSignupMessage = submittedFormDataInvalid
.map { _ in "Unsuccessful" }
24:47 To create the alertMessage
signal, we merge two sources: the
successful and unsuccessful message signals. We can use a helper method to
create a signal that takes other signals of the same type and emits when any of
those signals ping:
self.alertMessage = Signal.merge(
successfulSignupMessage,
unsuccessfulSignupMessage
)
25:27 The third alert message we have to write is the case where the
user has attempted to submit the form three times with invalid data. Again, we
take
the submittedFormDataInvalid
signal, but we ignore it the first two
times:
let tooManyAttemptsMessage = submittedFormDataInvalid
.skip(first: 2)
.map { _ in "Too Many Attempts" }
26:25 This allows invalid form data to be submitted twice, but only on the third attempt does this signal emit.
26:35 We add this signal to the alert message:
self.alertMessage = Signal.merge(
successfulSignupMessage,
unsuccessfulSignupMessage,
tooManyAttemptsMessage
)
26:43 It looks like we forgot something because our tests aren't passing yet. When inspecting the alert message history, we see that we receive an "Unsuccessful" value one time too many. This comes from the unsuccessful signup message signal, which should stop emitting after two times. We fix it by only taking the first two pings of the invalid form data signal:
let unsuccessfulSignupMessage = submittedFormDataInvalid
.take(first: 2)
.map { _ in "Unsuccessful" }
27:29 Now the test passes.
27:44 Just as we merged a few signals to create the alert message, we'll also use a few sources and combine them to define the output for the submit button's enabled state. We know that when the view loads, the button should start out disabled:
self.submitButtonEnabled = Signal.merge(
self.viewDidLoadProperty.signal.map { _ in false }
}
28:40 We want to enable the button once the form fields are present
(i.e. they have data). We use the isPresent
helper, which checks the character
count of the given inputs:
self.submitButtonEnabled = Signal.merge(
self.viewDidLoadProperty.signal.map { _ in false },
formData.map(isPresent(email:name:password:))
}
29:12 Finally, we want the submit button's enabled state to be false
after too many attempts:
self.submitButtonEnabled = Signal.merge(
self.viewDidLoadProperty.signal.map { _ in false },
formData.map(isPresent(email:name:password:),
tooManyAttemptsMessage.map { _ in false }
}
29:43 Only one test still isn't passing. After too many signup
attempts, the submit button doesn't stay disabled forever. When we fix the
invalid email address, the formData
signal emits, causing
submitButtonEnabled
to emit again as well. We have to make
submitButtonEnabled
stop emitting after too many attempts:
self.submitButtonEnabled = Signal.merge(
self.viewDidLoadProperty.signal.map { _ in false },
formData.map(isPresent(email:name:password:)),
tooManyAttemptsMessage.map { _ in false }
)
.take(until: tooManyAttemptsMessage.map { _ in () } )
31:12 Once too many attempts occur, the submit button signal will stop emitting forever. All our tests are now passing!
31:38 Judging from the tests we wrote, we already have a working app. But we're missing one step: we have to implement the view model in the view controller. To do this, we create an instance of our view model:
class MyViewController: UIViewController {
let vm: MyViewModelType = MyViewModel()
// ...
}
32:22 Now we can use the view model's inputs and outputs in the view controller's methods. The view model's interface is so concise that it's immediately obvious how we should use it. We start with the inputs:
override func viewDidLoad() {
super.viewDidLoad()
// ...
self.vm.inputs.viewDidLoad()
}
func submitButtonPressed() {
self.vm.inputs.submitButtonPressed()
}
func emailChanged() {
self.vm.inputs.emailChanged(email: self.emailTextField.text)
}
func nameChanged() {
self.vm.inputs.nameChanged(name: self.nameTextField.text)
}
func passwordChanged() {
self.vm.inputs.passwordChanged(password: self.passwordTextField.text)
}
34:14 And we finish by using the view model's two outputs. We observe
the output signals on a UIScheduler
, which makes sure all work is performed on
the main queue. With observeValues
, we specify a closure that receives new
values from the signal and uses them to update the interface:
override func viewDidLoad() {
super.viewDidLoad()
// ...
self.vm.outputs.alertMessage
.observe(on: UIScheduler())
.observeValues { [weak self] message in
let alert = UIAlertController(title: nil,
message: message,
preferredStyle: .alert)
alert.addAction(.init(title: "OK", style: .default, handler: nil))
self?.present(alert, animated: true, completion: nil)
}
self.vm.outputs.submitButtonEnabled
.observe(on: UIScheduler())
.observeValues { [weak self] enabled in self?.submitButton.isEnabled = enabled }
// ...
}
36:24 We can finally run our app and see how it works in an interactive Playground tab. We enter form data and get a successful signup. If we enter an invalid email address and submit, we get the unsuccessful message, and when repeatedly pressing the submit button, the form gets disabled and stays that way. Great!
37:50 It's impressive how we could write the whole test suite before writing the logic or even looking at or touching the interface.
38:17 Throughout Kickstarter's codebase, we've seen the same pattern of separating inputs and outputs, writing clear tests, and combining signals. Once you have a basic knowledge of how this works, the code becomes very easy to understand.
Written in Swift 3.1
Become a subscriber to download episode videos.
4 Episodes · 2h01min
Episode 431 · Nov 29
Episode 430 · Nov 22
Episode 429 · Nov 15
Episode 428 · Nov 08
Episode 427 · Nov 01
Episode 426 · Oct 25
Episode 425 · Oct 18
Episode 424 · Oct 11
Unlock Full Access
A new episode every week
Take Swift Talk with you when you're offline
With your help we can keep producing new episodes