This talk was given at FrenchKit 2019 which took place in Paris, France on 7-8 October.
With SwiftUI and Combine, Apple is changing its approach for how we define data flows and UI. As we move from playing with sample code to writing production apps, it’s time to start thinking about testing. Nataliya will show how to apply several learnings from her experience with declarative UIs to this new reality.
17. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
18. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
return AnyView(
Button(action: enableSecretFeature) {
Text("Enable a secret feature!”)
}
)
} else {
return AnyView(
Text("Ask admin to enable the secret feature.")
)
}
19. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
return AnyView(
Button(action: enableSecretFeature) {
Text("Enable a secret feature!”)
}
)
} else {
return AnyView(
Text("Ask admin to enable the secret feature.")
)
}
}
else {
return AnyView(EmptyView())
}
}
}
20. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
return AnyView(
Button(action: enableSecretFeature) {
Text("Enable a secret feature!”)
}
)
} else {
return AnyView(
Text("Ask admin to enable the secret feature.")
)
}
}
else {
return AnyView(EmptyView())
}
}
}
21. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
return AnyView(
Button(action: enableSecretFeature) {
Text("Enable a secret feature!”)
}
)
} else {
return AnyView(
Text("Ask admin to enable the secret feature.")
)
}
}
else {
return AnyView(EmptyView())
}
}
}
22. struct PageFooter: View {
let user: User
let enableSecretFeature: () -> Void
var body: some View {
if user.flags.contains(.newSecretFeature) {
if user.permissions.contains(.updateOrganizationSettings) {
return AnyView(
Button(action: enableSecretFeature) {
Text("Enable a secret feature!”)
}
)
} else {
return AnyView(
Text("Ask admin to enable the secret feature.")
)
}
}
else {
return AnyView(EmptyView())
}
}
}
How to test that we get the right view?
23. import XCTest
import SwiftUI
func testAdminUserInTestGroupSeesCTA() {
let view = PageFooter(
user: User.testGroupUser_admin,
enableSecretFeature: { }
)
let body = view.body
XCTAssertNotNil(body)
// Assert that we got a button... How?
}
24. import XCTest
import SwiftUI
func testAdminUserInTestGroupSeesCTA() {
let view = PageFooter(
user: User.testGroupUser_admin,
enableSecretFeature: { }
)
let body = view.body
XCTAssertNotNil(body)
// Assert that we got a button... How?
print(body)
}
Console output:
AnyView(storage: SwiftUI.(unknown context at
$7fff2c5f1e64).AnyViewStorage<SwiftUI.Button<SwiftUI.Text>>)
25. import XCTest
import SwiftUI
func testAdminUserInTestGroupSeesCTA() {
let view = PageFooter(
user: User.testGroupUser_admin,
enableSecretFeature: { }
)
let body = view.body
XCTAssertNotNil(body)
// Assert that we got a button... How?
print(body)
}
More realistic output:
AnyView(storage: SwiftUI.(unknown context at
$7fff2c5f1e64).AnyViewStorage<SwiftUI.VStack<SwiftUI.TupleView<(SwiftUI.
ModifiedContent<SwiftUI.Text, SwiftUI._AlignmentWritingModifier>,
SwiftUI.ModifiedContent<SwiftUI.Button<SwiftUI.Text>,
SwiftUI._BackgroundModifier<SwiftUI.Color>>)>>>)
27. import SwiftUI
struct PageFooter: View {
enum Mode {
case showsEnableCTA(Action)
case featureReadOnlyInfo
case empty
}
// Explicit input
let mode: Mode
28. import SwiftUI
struct PageFooter: View {
enum Mode {
case showsEnableCTA(Action)
case featureReadOnlyInfo
case empty
}
// Explicit input
let mode: Mode
var body: some View {
// No data transformations; views only
switch mode {
case .showsEnableCTA(let action):
return AnyView(Button(action: action) { Text(mode.title) })
case .featureReadOnlyInfo:
return AnyView(Text(mode.title))
case .empty:
return AnyView(EmptyView())
}
}
}
29. import Foundation
// “Glue” to application data like user & services
extension PageFooter.Mode {
init(user: User, service: SecretFeatureService) {
guard user.flags.contains(.newSecretFeature) else {
self = .empty
return
}
if user.permissions.contains(.updateOrganizationSettings) {
self = .showsEnableCTA(service.enable)
} else {
self = .showsTextDescription
}
}
}
31. func testUserInControlGroupDoesNotSeeSpecialView() {
// given that the user is in the control group
let user = User.controlGroupUser
// when we create a view for that user
let view = PageFooter(
user: user,
service: SecretFeatureService(enable: { })
)
32. func testUserInControlGroupDoesNotSeeSpecialView() {
// given that the user is in the control group
let user = User.controlGroupUser
// when we create a view for that user
let view = PageFooter(
user: user,
service: SecretFeatureService(enable: { })
)
// then the mode of the view will be empty
XCTAssertEqual(view.mode, .empty)
}
33. func testUserInControlGroupDoesNotSeeSpecialView() {
// given that the user is in the control group
let user = User.controlGroupUser
// when we create a view for that user
let view = PageFooter(
user: user,
service: SecretFeatureService(enable: { })
)
// then the mode of the view will be empty
XCTAssertEqual(view.mode, .empty)
}
✅
34. func testAdminUserInTestGroupSeesCTA() {
// given that the user is admin and is in the test group
let user = User.testGroupUser_admin
// when we create a view for that user with a specific action
let enableServiceCalled = XCTestExpectation(
description: "enable called”
)
let view = PageFooter(
user: user,
service: SecretFeatureService(
enable: { enableServiceCalled.fulfill() }
)
)
// then the mode of the view will be enable CTA
// and the action will be connected to the SecretFeatureService
if case let .showsEnableCTA(action) = view.mode {
action()
} else {
XCTFail("Wrong mode: (view.mode)")
}
wait(for: [enableServiceCalled], timeout: 0.001)
}
35. func testAdminUserInTestGroupSeesCTA() {
// given that the user is admin and is in the test group
let user = User.testGroupUser_admin
// when we create a view for that user with a specific action
let enableServiceCalled = XCTestExpectation(
description: "enable called”
)
let view = PageFooter(
user: user,
service: SecretFeatureService(
enable: { enableServiceCalled.fulfill() }
)
)
// then the mode of the view will be enable CTA
// and the action will be connected to the SecretFeatureService
if case let .showsEnableCTA(action) = view.mode {
action()
} else {
XCTFail("Wrong mode: (view.mode)")
}
wait(for: [enableServiceCalled], timeout: 0.001)
}
36. func testAdminUserInTestGroupSeesCTA() {
// given that the user is admin and is in the test group
let user = User.testGroupUser_admin
// when we create a view for that user with a specific action
let enableServiceCalled = XCTestExpectation(
description: "enable called”
)
let view = PageFooter(
user: user,
service: SecretFeatureService(
enable: { enableServiceCalled.fulfill() }
)
)
// then the mode of the view will be enable CTA
// and the action will be connected to the SecretFeatureService
if case let .showsEnableCTA(action) = view.mode {
action()
} else {
XCTFail("Wrong mode: (view.mode)")
}
wait(for: [enableServiceCalled], timeout: 0.001)
}
37. func testAdminUserInTestGroupSeesCTA() {
// given that the user is admin and is in the test group
let user = User.testGroupUser_admin
// when we create a view for that user with a specific action
let enableServiceCalled = XCTestExpectation(
description: "enable called”
)
let view = PageFooter(
user: user,
service: SecretFeatureService(
enable: { enableServiceCalled.fulfill() }
)
)
// then the mode of the view will be enable CTA
// and the action will be connected to the SecretFeatureService
if case let .showsEnableCTA(action) = view.mode {
action()
} else {
XCTFail("Wrong mode: (view.mode)")
}
wait(for: [enableServiceCalled], timeout: 0.001)
}
✅
39. import SwiftUI
struct PageFooter: View {
...
// Current mode can be observed through an observable object
@ObservedObject var viewModel: PageFooterViewModel
...
}
40. import Foundation
import Combine
class PageFooterViewModel: ObservableObject {
@Published var mode: PageFooter.Mode
init(appState: AppState) {
// same as before
mode = PageFooter.Mode(
user: appState.user,
service: appState.secretFeatureService
)
41. import Foundation
import Combine
class PageFooterViewModel: ObservableObject {
@Published var mode: PageFooter.Mode
init(appState: AppState) {
// same as before
mode = PageFooter.Mode(
user: appState.user,
service: appState.secretFeatureService
)
// `mode` current and future values derived from the app state
sinkSubscription = appState.$user
.combineLatest(appState.$secretFeatureService)
.map { PageFooter.Mode(user: $0, service: $1) }
.sink { self.mode = $0 }
}
}
42. import Foundation
import Combine
class PageFooterViewModel: ObservableObject {
@Published var mode: PageFooter.Mode
private var sinkSubscription: AnyCancellable? = nil
init(appState: AppState) {
// same as before
mode = PageFooter.Mode(
user: appState.user,
service: appState.secretFeatureService
)
// `mode` current and future values derived from the app state
sinkSubscription = appState.$user
.combineLatest(appState.$secretFeatureService)
.map { PageFooter.Mode(user: $0, service: $1) }
.sink { self.mode = $0 }
}
}
44. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
45. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
// when we create a view model for that state
let viewModel = PageFooterViewModel(appState: appState)
46. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
// when we create a view model for that state
let viewModel = PageFooterViewModel(appState: appState)
// then the mode of the view will be read only
XCTAssertEqual(viewModel.mode, .featureReadOnlyInfo)
47. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
// when we create a view model for that state
let viewModel = PageFooterViewModel(appState: appState)
// then the mode of the view will be read only
XCTAssertEqual(viewModel.mode, .featureReadOnlyInfo)
// when app state changes
appState.user.flags = []
48. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
// when we create a view model for that state
let viewModel = PageFooterViewModel(appState: appState)
// then the mode of the view will be read only
XCTAssertEqual(viewModel.mode, .featureReadOnlyInfo)
// when app state changes
appState.user.flags = []
// then the mode changes too
XCTAssertEqual(viewModel.mode, .empty)
}
49. func testUserInTestGroupWillSeeFeatureInfo() {
// given that the user is in the test group
let user = User.testGroupUser
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService(enable: {})
)
// when we create a view model for that state
let viewModel = PageFooterViewModel(appState: appState)
// then the mode of the view will be read only
XCTAssertEqual(viewModel.mode, .featureReadOnlyInfo)
// when app state changes
appState.user.flags = []
// then the mode changes too
XCTAssertEqual(viewModel.mode, .empty)
}
✅
50. Views should be dumb.
Try not to hide business logic in UI code.
Navigation is business logic too.
51. struct PageFooter: View {
var body: some View {
// No data transformations; views only
switch mode {
case .showsEnableCTA(let action):
return AnyView(Button(action: action) { Text(mode.title) })
case .featureReadOnlyInfo:
return AnyView(Text(mode.title))
case .empty:
return AnyView(EmptyView())
}
}
}
52. struct PageFooter: View {
var body: some View {
// No data transformations; views only
switch mode {
case .showsEnableCTA(let action):
return AnyView(
NavigationLink(
destination: ???,
label: {
Text(mode.title)
}
)
) case .featureReadOnlyInfo:
return AnyView(Text(mode.title))
case .empty:
return AnyView(EmptyView())
}
}
}
Side effect
53. class SecretFeatureService: ObservableObject {
@Published var showingActivationFlow: Bool = false
func enable() {
showingActivationFlow = true
}
static let `default` = SecretFeatureService()
}
55. class PageViewModel: ObservableObject {
let footer: PageFooterViewModel
var showingActivationFlow: Binding<Bool> {
return Binding<Bool>(
get: { self.showingActivationSubject.value },
set: { _ in }
)
}
private var subscription: AnyCancellable? = nil
private let showingActivationSubject: CurrentValueSubject<Bool, Never>
init(appState: AppState) {
footer = PageFooterViewModel(appState: appState)
showingActivationSubject = CurrentValueSubject(
appState.secretFeatureService.showingActivationFlow
)
subscription = appState
.secretFeatureService
.$showingActivationFlow
.sink { [weak self] in
self?.showingActivationSubject.value = $0
}
}
}
56. func testCTATriggersActivationFlow() {
// given that the user is an admin
let user = User.testGroupUser_admin
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService()
)
// and we have a view model for that state
let viewModel = PageViewModel(appState: appState)
57. func testCTATriggersActivationFlow() {
// given that the user is an admin
let user = User.testGroupUser_admin
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService()
)
// and we have a view model for that state
let viewModel = PageViewModel(appState: appState)
if case let .showsEnableCTA(action) = viewModel.footer.mode {
// given showingActivationFlow is false
XCTAssertFalse(viewModel.showingActivationFlow.wrappedValue)
// when the CTA ation executes
action()
// then showingActivationFlow is true
XCTAssertTrue(viewModel.showingActivationFlow.wrappedValue)
} else {
XCTFail()
}
}
58. func testCTATriggersActivationFlow() {
// given that the user is an admin
let user = User.testGroupUser_admin
// and we have an App state with that user
let appState = AppState(
user: user,
secretFeatureService: SecretFeatureService()
)
// and we have a view model for that state
let viewModel = PageViewModel(appState: appState)
if case let .showsEnableCTA(action) = viewModel.footer.mode {
// given showingActivationFlow is false
XCTAssertFalse(viewModel.showingActivationFlow.wrappedValue)
// when the CTA ation executes
action()
// then showingActivationFlow is true
XCTAssertTrue(viewModel.showingActivationFlow.wrappedValue)
} else {
XCTFail()
}
}
✅
62. Debug previews
1. Create a view model object
2. Configure the view with the view model
3. Customise the preview configuration (device, accessibility settings, etc)
4. See the view rendered with the provided configuration
A. Anytime the code changes, the preview refreshes
71. Snapshot testing
1. Create a view model object
2. Configure the view with the view model
3. Customise the snapshot configuration (device, accessibility settings, etc)
4. Snapshot the view rendered with the provided configuration
A. If snapshotting for the first time → save the snapshot
B. Otherwise assert saved and new snapshots match
72. Snapshot testing
1. Create a view model object
2. Configure the view with the view model
3. Customise the snapshot configuration (device, accessibility settings, etc)
4. Snapshot the view rendered with the provided configuration
A. If snapshotting for the first time → save the snapshot
B. Otherwise assert saved and new snapshots match
73. Snapshot testing
1. Create a view model object
2. Configure the view with the view model
3. Customise the snapshot configuration (device, accessibility settings, etc)
4. Snapshot the view rendered with the provided configuration
A. If snapshotting for the first time → save the snapshot
B. Otherwise assert saved and new snapshots match
74. Debug previews
1. Create a view model object
2. Configure the view with the view model
3. Customise the preview configuration (device, accessibility settings, etc)
4. See the view rendered with the provided configuration
99. 🥡
1. SwiftUI may be new but declarative UI isn’t. We can leverage past experience to
improve testing.
2. We can make our views more testable by extracting their business logic.
3. We can make sure that we notice regressions by combining debug previews and
snapshot testing.
4. We can try and extract navigation from views but it’s difficult right now.