25 Mar 2022
UI test automation continued… where did we get to?
In the previous post, we fleshed out our onboarding flow and added UI tests for covering onboarding and the transition to our main content view.
In so doing, we hit the issue of test pollution as a result of using shared UserDefaults
across tests.
In this post, we’ll address this issue by:
- Introducing a
Dependencies
environment object that we’ll use app-wide for our concrete dependencies.
- Refactoring our
SettingStore
to use a protocol for UserDefaults
- Introducing an in-memory
UserDefaults
replacement that we can configure for our tests
Let’s review our SettingStore
implementation
final class SettingStore: SettingStorage {
static let shared = SettingStore()
private init() {}
var showOnboarding: Bool {
get {
!UserDefaults.standard.bool(forKey: "hasOnboardingBeenShown")
}
set {
UserDefaults.standard.set(!newValue, forKey: "hasOnboardingBeenShown")
}
}
}
What’s wrong here? well, a number of things…
UserDefaults.standard
is a shared resource that can be mutated by any test in our test suite, at any time (if tests are run in parallel), this is our core issue
- We’re tightly coupling this class with the concrete class
UserDefaults
- We can’t safely unit test
SettingStore
given it’s reliance on a concretion, not an abstraction
- We’re using string constants which are less maintainable and more error prone (typos are easy to make and hard to spot!)
Let’s address all of these issues. As we’re currently only interacting with UserDefaults
via a boolean
let’s just handle that scenario for now. We’ll start by introducing an enum for our settings keys.
enum SettingStoreKey: String {
case hasOnboardingBeenShown
}
Then we’ll introduce an abstraction around our UserDefaults
scenario.
protocol UserDefaultInterfacing {
func set(_ value: Bool, forKey key: SettingStoreKey)
func bool(forKey key: SettingStoreKey) -> Bool
}
Before we conform UserDefaults
to it, note we’re using our SettingStoreKey
here, this will make the call site nicer to work with.
extension UserDefaults: UserDefaultInterfacing {
func set(_ value: Bool, forKey key: SettingStoreKey) {
set(value, forKey: key.rawValue)
}
func bool(forKey key: SettingStoreKey) -> Bool {
bool(forKey: key.rawValue)
}
}
Finally, we update our SettingStore
with the injected abstraction with UserDefaults
as our default for our app.
NOTE: We remove the private initialiser and our singleton as we want to ensure we’re using the correct instance everywhere.
final class SettingStore: SettingStorage {
let userDefaults: UserDefaultInterfacing
init(userDefaults: UserDefaultInterfacing = UserDefaults.standard) {
self.userDefaults = userDefaults
}
var showOnboarding: Bool {
get {
!userDefaults.bool(forKey: .hasOnboardingBeenShown)
}
set {
userDefaults.set(!newValue, forKey: .hasOnboardingBeenShown)
}
}
}
Great! now we’ve got a unit-testable SettingStore
and a reusable abstraction over UserDefaults
.
Let’s move on to our UI test affordance, we’ll create a non-persisted in-memory cached dictionary equivalent of UserDefaults
.
final class InMemoryUserDefaults: UserDefaultInterfacing {
private var cache: [String: Bool] = [:]
func set(_ value: Bool, forKey key: SettingStoreKey) {
cache[key.rawValue] = value
}
func bool(forKey key: SettingStoreKey) -> Bool {
cache[key.rawValue] ?? false
}
}
NOTE: This is a naive implementation, we’re not handling additional functionality present in UserDefaults
such as the ability to register defaults
. If your app needs this, bear that in mind.
Cool! let’s move on to our UI test interface through LaunchArgumentConfigurator
.
enum LaunchArgumentConfigurator {
static func configure(
_ dependencies: Dependencies,
with launchArguments: [String]
) {
if launchArguments.contains(LaunchArgumentKey.useInMemoryUserDefaults.rawValue) {
dependencies.replace(with: SettingStore(userDefaults: InMemoryUserDefaults()))
}
if launchArguments.contains(LaunchArgumentKey.skipOnboarding.rawValue) {
dependencies.settingStore.showOnboarding = false
}
}
}
enum LaunchArgumentKey {
// NOTE: We add a key to use for UI tests
case useInMemoryUserDefaults = "-useInMemoryUserDefaults"
...
}
Wait, where did AutomationContext
go? and what is Dependencies
?
Let me show you what Dependencies
does and we’ll circle back.
final class Dependencies {
static let shared = Dependencies()
private(set) var settingStore: SettingStorage
private init(settingStore: SettingStorage = SettingStore(userDefaults: UserDefaults.standard)) {
self.settingStore = settingStore
}
func replace(with settingStore: SettingStorage) {
self.settingStore = settingStore
}
}
So Dependencies
is a simple dependency container we can use to inject either the app UserDefaults
implementation or our in-memory test alternative.
NOTE: When it comes to implementing Networking in our app, we could use this same dependency container approach in order to switch between an app-default or a static, offline alternative.
If you build at this point, you’d notice there’s an error here:
dependencies.settingStore.showOnboarding = false
With the error:
Cannot assign to property: 'settingStore' setter is inaccessible
.
This is because our SettingStorage protocol
isn’t type-constrained so it could be conformed to by an immutable struct
or a class
. If it were a struct
, the compiler can’t tell if it would be mutable hence the error. We need to be more specific. Here I’ll just say SettingStorage
has to be implemented by a class
by constraining to AnyObject
this limits SettingStorage
to classes
exclusively which, as reference types, are freely mutable:
protocol SettingStorage: AnyObject {
So, where did AutomationContext
go? Well, for now, it’s performing the same role as Dependencies
so we’ve removed it, however as we build other UI-test specific flows we may bring it back.
Let’s update any references to SettingStore.shared
with Dependencies.shared.settingStore
.
final class AppViewModel: ObservableObject {
init(settingStore: SettingStorage = Dependencies.shared.settingStore)
...
}
The very last task is to update our UI tests so they trigger use of our safe, testable in-memory alternative.
In BaseUITestCase
we add:
func useTestSafeUserDefaults() {
launchArguments.insert(LaunchArgumentKey.useInMemoryUserDefaults.rawValue)
}
We could add this call in both our OnboardingView
and ContentView
tests, however as we want all our UI tests to be safe and predictable by default, we’ll add it to our BaseUITestCase
’s setUp
.
override func setUp() {
super.setUp()
...
launchArguments = Set<String>(app.launchArguments)
useTestSafeUserDefaults()
}
Let’s re-run our tests, in parallel and randomly and run them 100 times to be sure we fixed the test pollution issue.
Here’s how you set up parallel test running, go to Scheme > Tests > Info > Options
:

Here’s how you set up repeated test runs:
Right-click your UI test project and pick run repeatedly

Decide on your scenario and conditions

The result?
All our tests pass, in any order, regardless of being run serially or in parallel.
As Paul Hudson points out tests should be FIRST:
Fast, Isolated, Repeatable, Self-Verifying and Timely.
Fast: UI tests are much slower than unit tests as they have a lot more setup required before they can run and a higher resource overhead when running but swapping to an in-memory replacement rather than a file-IO backed UserDefaults
actually does speed our test up.
Isolated: We’ve isolated one of the dependencies, we’ve eliminated a reason for the tests to fail
Repeatable:
That’s what we’ve improved with the changes in this post, by isolating UserDefaults
our tests can now be run in parallel, in any order with the same repeatable results. No test flakeyness in sight.
Self-Verifing:
Our tests need to be valuable, it’s easy to increase code coverage with UI tests just by interacting with a screen but if you’re not verifing state and behaviour with assertions that coverage is a lie, those tests are meaningless.
In our case we’re testing both UI state as well as inter-screen navigation behaviour.
Timely:
Here’s it’s referring to TDD, “you should know what you’re trying to build before you build” it.
For the format and focus of this series I didn’t follow TDD but it’s a great technique, if you haven’t tried it before, give it a go!
So what did we cover?
- We introduced a Dependency container that we’ll use app-wide for our replaceable concrete dependencies.
- We refactored our
SettingStore
to use an injectable protocol for UserDefaults
, making our SettingsStore
unit testable.
- We introduced an in-memory
UserDefaults
replacement that we configured through our UI tests
- We added predictability to our common test case, ran our UI tests and proved that we’ve fixed our core issues.
What do we do next?
- We’ll take a look at approaches for handling networking.
- We’ll also look at how you can wait for state changes that take time (for animations to finish or networking to complete, for example).
I hope this post was informative, feel free to send me your thoughts via Twitter.
Footnotes
24 Mar 2022
UI test automation continued… where did we get to?
In the previous post, we covered encapsulating automation and launch argument constants, encapsulated launch argument configuration for our UI tests and wrapped our interactions and assertions in a Robot screen to make our tests easy to read and understand.
In this post we’ll:
- Flesh out our
Onboarding
flow views with some more complexity
- We’ll write UI tests for
Onboarding
to address the changes in design
- Discover the fundamental flaw in our
SettingStore
implementation
Let’s make onboarding a bit more complex…
Let’s introduce a 3 stage onboarding process. I’ll model that with an OnboardingStage
enum.
For the sake of brevity, I’ll extend this enum to return content-specific to the stage.
enum OnboardingStage: Int, CaseIterable {
case welcome
case catalog
case confirm
var icon: Image { ... }
var title: String { ... }
var body: String { ... }
var buttonTitle: String { ... }
var buttonColour: Color { ... }
var buttonAutomationId: AutomationIdentifying {
if self == .confirm {
return Automation.OnboardingScreen.complete
} else {
return Automation.OnboardingScreen.next
}
}
}
Then a trimmed version of our new onboarding view.
struct OnboardingView: View {
@State var stage: OnboardingStage = .welcome
let complete: () -> Void
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .bottom) {
Color.clear
VStack {
Self.onboardingPage(
for: stage,
in: geometry
)
Button(
action: {
if stage.isLast {
complete()
} else {
withAnimation {
stage.next()
}
}
},
label: {
Text(stage.buttonTitle)
.font(.system(.title3, design: .rounded))
.frame(maxWidth: .infinity)
.padding(.vertical)
.background(
RoundedRectangle(
cornerRadius: Style.cornerRadius,
style: .continuous
).fill(stage.buttonColour)
)
.foregroundColor(.primary)
})
.buttonStyle(.plain)
.automationId(stage.automationId)
}
.padding(.horizontal)
}
}
}
I’ve pulled the onboarding page view out for length but fundamentally it’s just this:
Image
Text(stage.title)
.automationId(Automation.OnboardingScreen.title)
Text(stage.body)
Tapping the Next
button goes through the stages from welcome
to catalogue
to confirmation
.
Tapping the Complete
button calls our complete: () -> Void
callback.
NOTE: Ideally you’d abstract this all behind a testable ViewModel
but there’s a lot to cover here so I won’t.
Here’s our three screens:

I’m sure you’ll forgive the design, it just adds some testable differences. In this case the title and button have Automation Ids
.
enum OnboardingScreen: String, AutomationIdentifying {
case title = "automation.onboarding.stage.title"
case complete = "automation.onboarding.complete"
case next = "automation.onboarding.next"
}
Let’s write some UI tests
final class OnboardingViewTests: BaseUITestCase {
override func setUp() {
super.setUp()
launch()
}
/*
GIVEN I start the app from scratch
WHEN the onboarding screen shows
THEN I see the welcome stage
*/
func testOnboarding_showsWelcomeStageByDefault() {
OnboardingScreen(app)
.isOnScreen()
.showsTitle("Welcome")
.isShowingNextButton()
}
}
struct OnboardingScreen {
private let app: XCUIApplication
init(_ app: XCUIApplication) {
self.app = app
}
private var title: XCUIElement { app.staticTexts[Automation.OnboardingScreen.title] }
private var nextButton: XCUIElement { app.buttons[Automation.OnboardingScreen.next] }
private var completeButton: XCUIElement { app.buttons[Automation.OnboardingScreen.complete] }
@discardableResult
func isOnScreen() -> Self {
XCTAssert(title.exists)
return self
}
@discardableResult
func showsTitle(_ text: String) -> Self {
XCTAssertEqual(text, title.label)
return self
}
@discardableResult
func isShowingNextButton() -> Self {
XCTAssert(nextButton.exists)
return self
}
@discardableResult
func isShowingCompleteButton() -> Self {
XCTAssert(completeButton.exists)
return self
}
}
We run our tests and… great! They pass. Let’s add tests for the next two stages.
We’ll add a next
and complete
interaction to our OnboardingScreen
@discardableResult
func next() -> Self {
XCTAssert(nextButton.exists)
nextButton.tap()
return self
}
@discardableResult
func complete() -> Self {
XCTAssert(completeButton.exists)
completeButton.tap()
return self
}
Then add our remaining UI tests.
/*
GIVEN I am on the welcome onboarding stage
WHEN I press the next button
THEN I am shown the catalogue stage
*/
func testOnboarding_isOnWelcomeStage_next_showsCatalogueStage() {
OnboardingScreen(app)
.isOnScreen()
.showsTitle("Welcome")
.next()
.showsTitle("Shiny, shiny things")
.isShowingNextButton()
}
/*
GIVEN I am on the catalogue onboarding stage
WHEN I press the next button
THEN I am shown the confirm stage
*/
func testOnboarding_isOnCatalogueStage_next_showsConfirmStage() {
OnboardingScreen(app)
.isOnScreen()
.next()
.next()
.showsTitle("Ready to start?")
.isShowingCompleteButton()
}
/*
GIVEN I am on the confirm onboarding stage
WHEN I press the complete button
THEN I am shown the content screen
*/
func testOnboarding_isOnConfirmStage_next_showsContentScreen() {
OnboardingScreen(app)
.isOnScreen()
.next()
.next()
.complete()
ContentScreen(app)
.isOnScreen()
}
What happens if I run this?
Well, it depends if you’ve got randomise execution order or parallel running configured for your tests.
If they’re run randomly and the last test is run first then all the other tests fail.
Why is this? I mentioned this in a previous post, we’re suffering from test pollution.
You see, the issue is that if you used the UserDefaults
-backed SettingStore
the last test ends up setting showOnboarding
to false
and as a result, when they access UserDefaults
they’re told not to show onboarding, instead we jump to the content screen
so our tests fail.
This is a big problem, right?
It absolutely is, and it applies to all persisted shared resources not just UserDefaults
.
So what did we cover?
- We added a staged onboarding process
- We added UI tests for our onboarding screens, we tested both the titles and buttons were as expected both by default and after interaction
- We realised we have a core testing problem to solve
What do we do next?
We roll our sleeves up and take a look at part 4 of this series where we address test pollution head-on.
I hope this post was informative, feel free to send me your thoughts via Twitter.
23 Mar 2022
UI test automation continued… where did we get to?
In the previous post, we covered the setup required to externally initialise and configure our app such that the onboarding app flow could be skipped for UI test purposes.
In this post we’ll:
- Introduce an approach for shared automation identifiers
- Improve our app initialisation via a shared typed enum
- Swap our string constants for enum-based ones
- Encapsulate our screens behaviours and assertions using the Robot pattern
- Pass our failing UI test verifying that our onboarding approach works
App-side LaunchArguments
We’ll start by addressing the launch arguments.
Let’s start on the app side by encapsulating the string constant into a LaunchArgumentKey enum
.
enum LaunchArgumentKey: String {
case skipOnboarding = "-skipOnboarding"
}
We’ll make this enum
shared across both the App and UI Test targets.
On the app side we’ll update our LaunchArgumentConfigurator
to use LaunchArgumentKey
.
enum LaunchArgumentConfigurator {
static func configure(
_ context: AutomationContext,
with launchArguments: [String]
) {
if launchArguments.contains(LaunchArgumentKey.skipOnboarding.rawValue) {
context.showOnboarding = false
}
}
}
NOTE: If we had more launch arguments, particularly ones with associated values we could do some more interesting and intelligent configuration but for now this is enough to increase maintainability.
Next, on the UI test side, we’ll introduce a helper class to better manage launch arguments. This gives us a reusable abstraction over launch arguments.
class BaseUITestCase: XCTestCase {
var app: XCUIApplication!
private var launchArguments = Set<String>()
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments.forEach { argument in
launchArguments.insert(argument)
}
}
override func tearDown() {
app = nil
super.tearDown()
}
func skipOnboarding() {
launchArguments.insert(LaunchArgumentKey.skipOnboarding.rawValue)
}
func launch() {
let arguments = launchArguments.reduce(into: [], { result, argument in
result.append(argument)
})
app.launchArguments = arguments
app.launch()
}
}
For context around the use of XCUIApplication!
see here.
Revisiting our UI test
Here’s our test case updated to use skipOnboarding
and launch
for the Main App Flow.
final class ContentViewTests: BaseUITestCase {
override func setUp() {
super.setUp()
skipOnboarding()
launch()
}
/*
GIVEN we've previously seen the onboarding flow
WHEN the app starts
THEN the main app flow is shown
*/
func testAfterSkippingOnboardingContentViewIsVisible() {
XCTFail("We can't assert anything yet")
}
}
Great, we’ve made it simple to skip onboarding as part of setUp
but we have nothing to assert we’re on the right view yet, let’s address that now.
An approach for accessibility identifiers
For us to verify we’re on a particular screen we need something to look for. In the app we add a shared enum modelled as screens with identifiable parts. Pick a naming convention that works for you and ensures uniqueness.
NOTE: We share the Automation
enum across both app and test targets.
enum Automation {
enum OnboardingScreen: String, AutomationIdentifying {
case complete = "automation.onboarding.complete"
}
enum ContentScreen: String, AutomationIdentifying {
case title = "automation.content.title"
}
}
protocol AutomationIdentifying {
var id: String { get }
}
extension AutomationIdentifying where Self: RawRepresentable, Self.RawValue == String {
var id: String { rawValue }
}
A Swift View extension helps us enforce type safety
extension View {
func automationId(_ identifying: AutomationIdentifying) -> some View {
accessibilityIdentifier(identifying.id)
}
}
Now, in our OnboardingView
we update our button with an identifier:
Button(
action: complete,
label: {
Text("Okay")
}
)
.automationId(
Automation
.OnboardingScreen
.complete
)
In our ContentView
we add our identifier:
Text("Our main app flow")
.automationId(
Automation
.ContentScreen
.title
)
Let’s update our UI test
func testAfterSkippingOnboardingContentViewIsVisible() {
let contentViewTitleElement = app.staticTexts[
Automation.ContentScreen.title.rawValue
]
XCTAssert(contentViewTitleElement.exists)
}
And our test passes, woot! … but imagine the other UI tests to follow that rely on us being on this screen, duplicating this same logic and having to know so much of the internals of the implementation.
…it’s Robot time
Here’s the approach we’ll take:
- Model a view or independent portions as a component/screen
- Use a fluent interface to chain behaviours and assertions
- For interactions use the imperative tense i.e commands such as
select
, next
, complete
- For assertions use the present tense
is
, has
, shows
etc
First, let’s introduce an XCUIElementQuery
helper so we can query for AutomationIdentifiers
directly.
extension XCUIElementQuery {
subscript(_ identifying: AutomationIdentifying) -> XCUIElement {
self[identifying.id]
}
}
Then we model our view as a screen hiding the implementation and exposing the assertions and interactions into a ‘Robot’:
struct ContentScreen {
private let app: XCUIApplication
init(_ app: XCUIApplication) {
self.app = app
}
private var title: XCUIElement {
app.staticTexts[Automation.ContentScreen.title]
}
@discardableResult
func isOnScreen() -> Self {
XCTAssert(title.exists)
return self
}
}
Our ContentScreen
Robot takes the app
instance to use and while this might feel like boilerplate, after all, when would we need another app? Well, in several important scenarios such as:
- When we need to access a platform screen such as accessing
Safari
with XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
- Or
App settings
with XCUIApplication(bundleIdentifier: "com.apple.Preferences")
We’ve covered a lot of ground already but let’s finally refactor our test.
func testAfterSkippingOnboardingContentViewIsVisible() {
ContentScreen(app)
.isOnScreen()
}
Looks good, it’s easy to read and understand but it’s a little too simple. Let’s tackle a more complex scenario next time.
What did we cover?
- A simple mechanism for starting the application in a pre-configured state through
AppLauncher
, LaunchArguments
, LaunchArgumentConfigurator
and AutomationContext
configured from UI tests.
BaseUITestCase
to encapsulate the understanding of launch argument configuration.
- A strongly-typed approach for accessibility identifiers via the
Automation
enums
- Encapsulating assertions and behaviours in a ‘Robot’ allows the call site to be easily readable and understandable.
- Passing our failing test and refactoring to use Robots.
What’s next?
- We’ll flesh out our
Onboarding
flow views
- Add some more advanced behaviours to test
- Add UI tests for our introduced
Onboarding
flow.
- Swap our dangerous use of
UserDefaults
for an AutomationContext
-led but in-memory alternative
I hope this post was informative, feel free to send me your thoughts via Twitter.
Footnotes:
23 Mar 2022
Who is this series for?
Anyone looking for a full end-to-end approach to UI testing in a pragmatic and predictable way
What this series isn’t:
- The only way to handle UI test automation
- Without tradeoffs that I’ll attempt to point out
- Covering when or why UI tests are a great choice
- Covering mocking network requests.
- Covering the alterations required for structured concurrency and actors
Here’s the TL;DR; of what I’ll cover in the series:
CommandLine
argument-based app initialisation
- An approach for app configuration (using SwiftUI as an example)
- How to isolate our UI test state across builds
- Some helpers for UI test scenario configuration
- A Robots-like approach for UI tests using a fluent interface
- The end-to-end illustrated
If that sounds interesting, read on.
Here are the key scenarios we’ll cover in the series:
- Our app has a lengthy first-run only onboarding flow that we want to skip for all but our onboarding UI tests
- We want to isolate our UI tests to use a different API endpoint, fetching ‘static’ data from a staging environment for example (see the risks here)
The core parts of this approach are an AppLauncher
as an entry point to allow us to read and configure our environment before the app is run.
An AutomationContext
acts as a live-defaulted environment we can use for configuring and tracking automation arguments.
A set of Automation Identifiers
shared between App
and UI tests
.
A Screen
or Robot
to make it easy to encapsulate assertions and interactions.
In this first post, we’ll cover the setup required to address our first scenario.
What to know before we start
UI tests run in their own process separate from your app and remotely interface with it. You’ll no doubt have seen this when you see "MyAppUITests-Runner"
installed in the simulator before your app is installed and run.
What does this mean? It means your app is mostly run like a black box where the only points of interface are on the initialisation of your app via launch arguments and through the accessibility engine that underpins XCTest.
Where does that leave us? With app initialisation via launch arguments as our primary means of configuring the app.
Let’s Skip Onboarding
Let’s imagine our simplified app looks something like this, when the app starts we initialise our state around onboarding.
@main
struct MyApp: App {
@StateObject var app = AppViewModel()
var body: some Scene {
WindowGroup {
if app.showOnboarding {
OnboardingView(
complete: {
app.markOnboardingSeen()
}
)
} else {
ContentView()
}
}
}
}
protocol SettingStorage {
var showOnboarding: Bool { get set }
}
final class AppViewModel: ObservableObject {
@Published private(set) var showOnboarding: Bool
private var settingStore: SettingStorage
init(settingStore: SettingStorage = SettingStore.shared) {
self.settingStore = settingStore
showOnboarding = settingStore.showOnboarding
}
func markOnboardingSeen() {
settingStore.showOnboarding = false
showOnboarding = false
}
}
An example SettingsStore
might just be a wrapper around UserDefaults
. For testability you should further abstract UserDefaults
to allow it to be injectable for testability and avoid resource isolation issues:
final class SettingStore: SettingStorage {
static let shared = SettingStore()
private init() {}
var showOnboarding: Bool {
get {
!UserDefaults.standard.bool(forKey: "hasOnboardingBeenShown")
}
set {
UserDefaults.standard.set(!newValue, forKey: "hasOnboardingBeenShown")
}
}
}
Introducing an AutomationContext
is the next step.
final class AutomationContext {
static let shared = AutomationContext()
private let settingStore: SettingStorage
var showOnboarding: Bool {
didSet {
settingStore.showOnboarding
}
}
private init(settingStore: SettingStorage = SettingStore.shared) {
self.settingStore = settingStore
showOnboarding = settingStore.showOnboarding
}
}
NOTE: Be aware of the dangers of using a UserDefault
-backed option like SettingsStore
. Not thinking through resources that are shared across tests, and simulators is a common cause of unexpected results and perceived test flakeyness.
Next, we need a way to pre-configure the automation context.
So let’s create an AppLauncher
which will grab the CommandLine
arguments we’ll use to configure the application run and a LaunchArgumentConfigurator
to parse our arguments and update our AutomationContext
and app state.
@main
enum AppLauncher {
static func main() throws {
LaunchArgumentConfigurator.configure(AutomationContext.shared, with: CommandLine.arguments)
MyApp.main()
}
}
// NOTE: We remove the @main annotation as AppLauncher is now our entry point
struct MyApp: App {...}
enum LaunchArgumentConfigurator {
static func configure(_ context: LaunchAutomationContext, with launchArguments: [String]) {
if launchArguments.contains("-skipOnboarding") {
context.showOnboarding = false
}
}
}
So what have we done? We’ve removed @main
from MyApp
and introduced a new entry point.
We’ve expanded the role of AutomationContext
to enable configuring our SettingsStore
before MyApp
is run and then finally we’ve started our app.
What are the downsides of this approach? Well, we’ve likely introduced some additional app start time as the settings store is initialised, read, and written to.
What have we gained here? The ability to unit test our LaunchArgumentConfigurator, AutomationContext, AppViewModel and SettingStore
via mutations to an injectable instance of SettingsStorable
before we even get to UI tests which can now be configured to skip onboarding via a launch argument.
How do we skip onboarding?
We just need to run the app with the launch argument "-skipOnboarding"
:
- You can do that in your scheme like so.

- Or via the launch argument of your app in a UI test
func testSkipsOnboarding() {
let app = XCUIApplication()
app.launchArguments.append("-skipOnboarding")
app.launch()
XCTFail("TODO: Verifying onboarding skipped")
}
What could we do better?
- We’ve left ourselves with a failing test, we should fix that in the next post
- We should abstract strings so they are maintainable and less prone to error
- Our use of
UserDefaults.standard
means that we haven’t isolated our settings across our tests or across different builds of the same app i.e if you had a Development vs Internal vs AppStore build they’d all share the same UserDefaults
at the moment. A better way of managing this would be to use an in-memory store for tests and a persisted one for production.
- Beware the impact of using persisted shared state and resources as they can lead to test pollution - a significant source of unexpected test behaviour. What is test pollution? Any resource that’s ultimately persisted to disk / synchronised in the cloud is shared across tests. Consider if your tests run in parallel, multiple simulators are instantiated running different tests at the same time which use the same files on disk. If
testMarkOnboardingAsSeen
updates UserDefaults.standard
with seen = true
and testMarkOnboardingAsUnseen
runs at the same time, they could easily read and write over each other and your expectations and assertions will fail inconsistently enough to send you on a wild goose chase and write off UI tests as ‘flakey’. Not flakey in this way, just incorrectly architected. We’ll address this in a future post.
- We rely on a mutation of
AutomationContext
to do work, hiding this in a property setter is a bit unexpected and easy to miss. A nicer way would be to keep sets private
and expose a method to allow this instead.
What’s next?
- Writing our first UI tests to verify our onboarding approach works.
- Introducing enum-based constants for strings and automation identifiers
- Introducing the Robot pattern
See the next post here.
I hope this post was informative, feel free to send me your thoughts via Twitter.
Footnotes:
19 Apr 2020
Take a breath
I’m about to tell you a dark tale. A story not so far from reality in a lot of codebases, as one might like to think.
Picture if you will, a core team building out an application over several years starting with a prototype, small in scope and with a well-defined architecture.
Over time it evolves as requirements and features change, different team members come and go, different philosophies, patterns and architectures are applied. In the dark recesses of the codebase, tech debt increases.
In time, the app reaches a point of sufficient size and complexity that no one person can keep the whole of the app in their head.
Not only that, the underlying data that drives the app is becoming more complex and mysterious with any manner of asynchronous or background processes mutating the data at any time.
The app is no longer predictable, unexpected state bugs manifest seemingly at random, inspecting the current state is nigh on impossible and debugging issues is mind-boggling. Then come the race conditions, multiple places wrestling each other, trying to update the data at the same time. You can barely see straight, sleep escapes you, you don’t know where you are anymore.
It’s okay, mop your brow, it was just a nightmare
You’re safe and among friends, this is but a cautionary tale to tell you about Redux, a state management pattern that can help alleviate and entirely avoid these kinds of horrors.
Thankfully the mobile teams I’ve worked with haven’t had issues quite this extreme to deal with but you should always open to potential code and quality improvements.
So, if you have no idea who, why or what is changing your data from one moment to the next, struggle with race conditions or order of operation bugs or find it hard to debug or inspect the state of your app, read on.
Still here? great, let’s dig into the central tenets of Redux:
Single source of truth
- The state of the app is stored in an object tree with a single point of access.
State is read only
- State can only be changed by dispatching
Actions
- Actions encapsulate the intent to transform the state
- All changes are made synchronously, applied one-by-one in a strict order
- This results in no race conditions
- Actions are simple objects and can be logged, serialised and easily tested
State is transformed by using pure functions
- Reducers are pure functions that take a previous state, an action to be applied and return the next state.
- Reducers are called in order and can be split into smaller reducers dealing with specific state
- Pure functions are super testable, pass an action, get a state back. Is it the expected state? Great! No need for mocks.
Three strong principles that hopefully you can already see the glimmer of utility in. It’s worth calling out that although these are the intended pillars and will serve you well, there’s a lot of nuance to how you might go about using it.
Principles aside, Redux consists of a few different parts.
Store
- Holds the
State
- Dispatches Actions
- Applies reducers to actions and exclusivey updates the state
Reducer
- A pure function taking the current
State
, an Action
and returns an updated State
- If a specific
Reducer
doesn’t handle the Action
then it may return State
unchanged.
Actions
- Primitive objects that contain the intended change and nothing more
- Keep free of reference types
There are optional components that can be added that I’ll try and cover later. Middleware
+ ActionCreators
both enable asynchronous actions.
In Swift
this could look something like this:
final class Store {
private let reducer: Reducer
private let serialDispatcher: DispatchQueueing
private let mainThreadDispatcher: Dispatching
private(set) var state: State
init(
state: State,
reducers: [Reducer],
serialDispatcher: DispatchQueueing,
mainThreadDispatcher: Dispatching
) {
self.serialDispatcher = serialDispatcher
self.mainThreadDispatcher = mainThreadDispatcher
let combinedReducers: Reducer = { state, action in
return reducers.reduce(state) { $1($0, action) }
}
self.reducer = combinedReducers
self.state = state
}
func dispatch(action: ActionProtocol) {
serialDispatcher.enqueue { [weak self] in
guard let self = self else { return }
let initialState = self.state
self.state = self.reducer(initialState, action)
}
}
}
protocol ActionDispatching {
func dispatch(action: ActionProtocol)
}
protocol ActionProtocol {}
typealias Reducer = (_ state: AppState, _ action: ActionProtocol) -> AppState
//NOTE: This could be implemented as an OperationQueue subclass with maxConcurrentOperationCount of 1
protocol DispatchQueueing {
func enqueue(_ block: @escaping() -> Void)
}
//NOTE: This could be a wrapped DispatchQueue
protocol Dispatching {
func async(_ block: @escaping () -> Void)
}
That’s it!
With this solution you can safely dispatch an action without fear, you can fully test every aspect of state mutation.
Except… what about asynchronous actions? what about state change notifications?
Good questions! Give yourself a pat on the back!
As with all programming problems there are any number of solutions.
Let’s start with asynchrony. There are two approaches that seem to have traction, Middleware
and Action Creators
.
Middleware
is called with an Action
before the State
has changed. Middleware
is not allowed to mutate the state and cannot block execution, it’s basically just an opportunity to kick start async operations, potentially with callbacks or long-running task completion handlers.
If Middleware
wants to update the State
it enqueues Actions
via the Store
.
Action Creators
, sometimes called Thunks
encapsulate a function, so rather than just being a plain old object containing data, it may act on that data too before dispatching an Action
itself on completion.
To avoid blocking, Action Creators
can be performed via Middleware
.
Essentially both allow you to encapsulate asynchronous actions without blocking, in slightly different ways. Pick your poison.
Let’s say you’re writing an app to sell Widgets
.
struct State {
let widgets: [Widget]
}
struct Widget {
let id: Int
let name: String
}
On your WidgetListViewController
you want to let users Refresh
the list of Widgets
so you call store.dispatch(RefreshWidgetsAction())
.
Here’s how we define our middleware:
protocol Middleware {
func apply(state: State, action: Action)
}
Our widget provider:
protocol WidgetProviding {
func provideWidgets(completion: () -> [Widget])
}
The middleware to perform async widget providing.
final class WidgetProviderMiddleware: Middleware {
private let widgetProvider: WidgetProviding
private let backgroundDispatcher: Dispatching
private let actionDispatcher: ActionDispatching
private let requestFrequencyLimitInSeconds: TimeInterval
private var widgetsLastProvided: Date = Date()
init(...)
func apply(state: State, action: Action) {
switch action {
case let action as RefreshWidgetsAction {
backgroundDispatcher.async { [weak self] in
//NOTE: Naively limit requests to once every N seconds
if Date() > widgetsLastProvided.addingTimeInterval(requestFrequencyLimitInSeconds) {
widgetProvider.provideWidgets { widgets in
//NOTE: Async action completed, let's update the state
actionDispatcher.dispatch(UpdateWidgetsAction(widgets: widgets))
widgetsLastProvided = Date()
}
}
}
}
Now the Reducer
to update the state:
func reduce(state: State, action: ActionProtocol) -> State {
switch action {
case let action as UpdateWidgetsAction:
return State(widgets: action.widgets)
default: return state
}
}
There we go, our WidgetListViewController
can dispatch RefreshWidgetActions
with no knowledge of what happens to it.
The Action
passes through the WidgetProviderMiddleware
which kicks off a network / database fetch operation and on completion the middleware dispatches a new action to update the Widgets
through a Reducer
.
There are other scenarios you might feasibly want to handle, maintaining load states, limiting request frequency etc. Note that if you start modelling load states you need to guarantee that those states are updated in failure as well as success paths.
It’s worth noting that your Redux
State
type should be considered model data, not VIEW data. Your view might ultimately transform the source State
before presentation but that separation should be maintained. Allowing your State
to grow massive may become a headache and result in performance problems. Don’t fill your State
with Data
or UIImage
s, store identifiers that can be loaded on demand. Your choices around allowing Optional
state might help if you wanted to allow partial State
loading.
Now, say you want to track certain events in the app, just add an AnalyticsMiddleware
, easy!
final class AnalyticsMiddleware: Middleware {
private let backgroundDispatcher: Dispatching
private let externalTracker: Tracking
init(...)
func apply(state: State, action: Action) {
switch action {
case let action as RefreshWidgetsAction {
backgroundDispatcher.async { [weak self] in
self?.externalTracker.refreshWidgetsRequested()
}
}
default: return
}
}
}
struct RefreshWidgetsAction: Action {}
So we’ve got State
mutation and Asynchronicity
locked down but our WidgetListViewController
doesn’t update yet!
Pick your choice of Observable
model, maybe you’re using RxSwift
, Combine
, KVO
or any other pattern that does Publisher / Subscriber notifications.
An example might be as simple as defining a listener:
protocol UpdateListener {
func stateUpdated()
}
In our Store
:
private var listeners: [UpdateListener]
private(set) var state: State {
didSet {
mainThreadDispatcher.async {
[weak self] in
self?.listeners.forEach {
$0.stateUpdated()
}
}
}
}
In our WidgetListViewController
:
//NOTE: Assumes our widgetListCollectionView data source accesses State on reload
extension WidgetListViewController: UpdateListener {
func stateUpdated() {
widgetListCollectionView.reloadData()
}
}
You can make it more reactive than that, but essentially that’s it end to end.
Conclusion
Hopefully, you can see the value such an approach might have.:
- Our
State
is consistent, predictable and (functionally) immutable.
- Every part is easily tested from the
Store
through the Reducers
and Middleware
without creating Mocks (cough except thread dispatchers).
- It’s easy to inspect the current state
- You can track every dispatched action end to end making it easy to debug.
There we go ladies, gentlemen and the plethora of goodness in between.
Redux, can it help save you from your nightmares?