-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathui-testing-deeplinks-and-universal-links-in-ios.html
403 lines (350 loc) · 21.4 KB
/
ui-testing-deeplinks-and-universal-links-in-ios.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://use.fontawesome.com/afd448ce82.js"></script>
<!-- Meta Tag -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- SEO -->
<meta name="author" content="Bruno Rocha">
<meta name="keywords" content="Software, Engineering, Blog, Posts, iOS, Xcode, Swift, Articles, Tutorials, OBJ-C, Objective-C, Apple">
<meta name="description" content="Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage.">
<meta name="title" content="UI Testing Deeplinks and Universal Links in iOS">
<meta name="url" content="https://swiftrocks.com/ui-testing-deeplinks-and-universal-links-in-ios">
<meta name="image" content="https://swiftrocks.com/images/thumbs/thumb.jpg?4">
<meta name="copyright" content="Bruno Rocha">
<meta name="robots" content="index,follow">
<meta property="og:title" content="UI Testing Deeplinks and Universal Links in iOS"/>
<meta property="og:image" content="https://swiftrocks.com/images/thumbs/thumb.jpg?4"/>
<meta property="og:description" content="Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage."/>
<meta property="og:type" content="website"/>
<meta property="og:url" content="https://swiftrocks.com/ui-testing-deeplinks-and-universal-links-in-ios"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:image" content="https://swiftrocks.com/images/thumbs/thumb.jpg?4"/>
<meta name="twitter:image:alt" content="Page Thumbnail"/>
<meta name="twitter:title" content="UI Testing Deeplinks and Universal Links in iOS"/>
<meta name="twitter:description" content="Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage."/>
<meta name="twitter:site" content="@rockbruno_"/>
<!-- Favicon -->
<link rel="icon" type="image/png" href="images/favicon/iconsmall2.png" sizes="32x32" />
<link rel="apple-touch-icon" href="images/favicon/iconsmall2.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap" rel="stylesheet">
<link rel="canonical" href="https://swiftrocks.com/ui-testing-deeplinks-and-universal-links-in-ios"/>
<!-- Bootstrap CSS Plugins -->
<link rel="stylesheet" type="text/css" href="css/bootstrap.css">
<!-- Prism CSS Stylesheet -->
<link rel="stylesheet" type="text/css" href="css/prism4.css">
<!-- Main CSS Stylesheet -->
<link rel="stylesheet" type="text/css" href="css/style48.css">
<link rel="stylesheet" type="text/css" href="css/sponsor4.css">
<!-- HTML5 shiv and Respond.js support IE8 or Older for HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://swiftrocks.com/ui-testing-deeplinks-and-universal-links-in-ios"
},
"image": [
"https://swiftrocks.com/images/thumbs/thumb.jpg"
],
"datePublished": "2020-09-15T14:00:00+02:00",
"dateModified": "2020-09-15T14:00:00+02:00",
"author": {
"@type": "Person",
"name": "Bruno Rocha"
},
"publisher": {
"@type": "Organization",
"name": "SwiftRocks",
"logo": {
"@type": "ImageObject",
"url": "https://swiftrocks.com/images/thumbs/thumb.jpg"
}
},
"headline": "UI Testing Deeplinks and Universal Links in iOS",
"abstract": "Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage."
}
</script>
</head>
<body>
<div id="main">
<!-- Blog Header -->
<!-- Blog Post (Right Sidebar) Start -->
<div class="container">
<div class="col-xs-12">
<div class="page-body">
<div class="row">
<div><a href="https://swiftrocks.com">
<img id="logo" class="logo" alt="SwiftRocks" src="images/bg/logo2light.png">
</a>
<div class="menu-large">
<div class="menu-arrow-right"></div>
<div class="menu-header menu-header-large">
<div class="menu-item">
<a href="blog">blog</a>
</div>
<div class="menu-item">
<a href="about">about</a>
</div>
<div class="menu-item">
<a href="talks">talks</a>
</div>
<div class="menu-item">
<a href="projects">projects</a>
</div>
<div class="menu-item">
<a href="software-engineering-book-recommendations">book recs</a>
</div>
<div class="menu-item">
<a href="games">game recs</a>
</div>
<div class="menu-arrow-right-2"></div>
</div>
</div>
<div class="menu-small">
<div class="menu-arrow-right"></div>
<div class="menu-header menu-header-small-1">
<div class="menu-item">
<a href="blog">blog</a>
</div>
<div class="menu-item">
<a href="about">about</a>
</div>
<div class="menu-item">
<a href="talks">talks</a>
</div>
<div class="menu-item">
<a href="projects">projects</a>
</div>
<div class="menu-arrow-right-2"></div>
</div>
<div class="menu-arrow-right"></div>
<div class="menu-header menu-header-small-2">
<div class="menu-item">
<a href="software-engineering-book-recommendations">book recs</a>
</div>
<div class="menu-item">
<a href="games">game recs</a>
</div>
<div class="menu-arrow-right-2"></div>
</div>
</div>
</div>
<div class="content-page" id="WRITEIT_DYNAMIC_CONTENT">
<!--WRITEIT_POST_NAME=UI Testing Deeplinks and Universal Links in iOS-->
<!--WRITEIT_POST_HTML_NAME=ui-testing-deeplinks-and-universal-links-in-ios-->
<!--Add here the additional properties that you want each page to possess.-->
<!--These properties can be used to change content in the template page or in the page itself as shown here.-->
<!--Properties must start with 'WRITEIT_POST'.-->
<!--Writeit provides and injects WRITEIT_POST_NAME and WRITEIT_POST_HTML_NAME by default.-->
<!--WRITEIT_POST_SHORT_DESCRIPTION=Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage.-->
<!--WRITEIT_POST_SITEMAP_DATE_LAST_MOD=2020-09-15T14:00:00+02:00-->
<!--WRITEIT_POST_SITEMAP_DATE=2020-09-15T14:00:00+02:00-->
<title>UI Testing Deeplinks and Universal Links in iOS</title>
<div class="blog-post">
<div class="post-title-index">
<h1>UI Testing Deeplinks and Universal Links in iOS</h1>
</div>
<div class="post-info">
<div class="post-info-text">
Published on 15 Sep 2020
</div>
</div>
<p>Did you know it's possible to terminate your app in the <i>middle</i> of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage.</p>
<div class="sponsor-article-ad-auto hidden"></div>
<p>It's tricky to test iOS features because while you can write unit tests to guarantee that your abstraction of it works, you can't really unit test that iOS will correctly call what you think will be called. In the case of deeplinks, what iOS does differs depending on what's the current state of your app (closed or in background) and which delegates you support (AppDelegate versus SceneDelegates), which commonly leads to very confusing bug reports in the point of view of the developer who isn't aware of this fact.</p>
<p>But unless you're for some reason not supporting iOS 11 in 2020, you can perfectly test "app launch" related features and any other AppDelegate/SceneDelegate related feature through UI Tests. This is because it was in iOS 11 where XCUI started supporting the ability to <b>launch and control system apps</b>. Today, we can make a test that boots Safari, types an URL and deeplinks back to our app. We can even terminate our app (which doesn't stop the test!) to check that our app behaves correctly if it's <i>launched</i> from said deeplink!</p>
<h2>UI Testing Deeplinks (from a backgrounded app)</h2>
<p>To begin, let's UI test a Safari deeplink when our app is already running in the background.</p>
<p>Launching other apps in the middle of a UI test is similar to launching our own, with the simple difference that you pass a different bundle identifier instead. In the case of Safari, the bundle identifier is <code>"com.apple.mobilesafari"</code>:</p>
<pre><code>func testDeeplinkFromSafari() {
let app = XCUIApplication()
app.launch()
let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
safari.launch()
}</code></pre>
<p>If you run this, you'll get a simple test that launches your app and switches to Safari right after.</p>
<p>Now, to deeplink back to our app, we can control Safari just like we would in a regular UI test. In this case, we can grab a hold of the address bar, type our link and press the "go" button. If everything works correctly Safari will deeplink back to our app, allowing us to assert that the deeplink logic in our app is working as expected.</p>
<pre><code>func testDeeplinkFromSafari() {
// Launch our app
let app = XCUIApplication()
app.launch()
// Launch Safari and deeplink back to our app
openFromSafari("swiftrocks://profile")
// Make sure Safari properly switched back to our app before asserting
XCTAssert(app.wait(for: .runningForeground, timeout: 5))
// Assert that the deeplink worked by checking if we're in the "Profile" screen
XCTAssertTrue(app.navigationBars["Profile"].exists)
}
private func openFromSafari(_ urlString: String) {
let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
safari.launch()
// Make sure Safari is really running before asserting
XCTAssert(safari.wait(for: .runningForeground, timeout: 5))
// Type the deeplink and execute it
let firstLaunchContinueButton = safari.buttons["Continue"]
if firstLaunchContinueButton.exists {
firstLaunchContinueButton.tap()
}
safari.buttons["URL"].tap()
let keyboardTutorialButton = safari.buttons["Continue"]
if keyboardTutorialButton.exists {
keyboardTutorialButton.tap()
}
safari.typeText(urlString)
safari.buttons["Go"].tap()
_ = confirmationButton.waitForExistence(timeout: 2)
if confirmationButton.exists {
confirmationButton.tap()
}
}</code></pre>
<p>It's good to add the additional <code>wait(for: .runningForeground)</code> assertion for safety as that makes the test check that the app switching actually worked before attempting to assert anything else. If it fails for some reason, you'll know it was because the app failed to switch instead of something not being present in the UI of your app.</p>
<p>You may also notice that there's some additional logic in our Safari handling; Safari sometimes shows a "What's new" screen, which we treat by first finding and tapping the <code>"Continue"</code> button, if it exists, which can also happen when opening the keyboard for the same time. Additionally, when executing deeplinks you might sometimes get a "Open in X?" confirmation, which is treated by finding and tapping the <code>"Open"</code> button.</p>
<h2>UI Testing Deeplinks (that launches the app / from a killed app)</h2>
<p>The issue I faced that prompted me to write this article is that iOS processes deeplinks differently according to the current state of the app. For example, in SceneDelegates, deeplinks will trigger your <code>scene(_:openURLContexts:)</code> method, but if the app is <b>launched</b> as a result of the deeplink, no method is called. Instead, you need to access it from the <code>urlContexts</code> property of your scene. Thus, when UI testing, you also need to have a test that operates on an app that is <b>not</b> running.</p>
<p>One may think that a UI test would fail if your app terminates, but that's actually not the case! You can make a test that terminates and reboots an app as much as you like by using these special methods from <code>XCUIApplication</code>:</p>
<pre><code>app.launch() // Launches the app (or reboots it/launches it again)
app.terminate() // Terminates the app (which does not stops the test!)
app.activate() // Puts the app in the foreground, if it was backgrounded</code></pre>
<p>As mentioned, if you terminate the app, you're free to launch it again in the same test. It's not necessary to reassign your <code>XCUIApplication</code> instance -- all assertions will work normally as if the app was never terminated in the first place.</p>
<p>Thus, to test that our deeplinks work correctly when the app <b>isn't</b> launched, we can simply close the app before opening Safari. It's not necessary to launch it again, as that will happen naturally as iOS attempts to open our deeplink.</p>
<pre><code>func testDeeplinkFromSafari_fromBackgroundedApp() {
openSafariDeeplink(terminateFirst: false)
}
func testDeeplinkFromSafari_thatLaunchesTheApp() {
openSafariDeeplink(terminateFirst: true)
}
func openSafariDeeplink(terminateFirst: Bool) {
let app = XCUIApplication()
app.launch()
if terminateFirst {
app.terminate()
}
// Launch Safari and deeplink back to our app
openFromSafari("swiftrocks://profile")
// Make sure Safari properly switched back to our app before asserting
XCTAssert(app.wait(for: .runningForeground, timeout: 5))
// Assert that the deeplink worked by checking if we're in the "Profile" screen
XCTAssertTrue(app.navigationBars["Profile"].exists)
}</code></pre>
<p>An alternative for this is to simply never call <code>app.launch()</code>, but personally I had mixed results with it. Launching the app also installs it, so never launching it resulted in flaky tests. Launching and terminating it however works 100% of the time.</p>
<h2>UI Testing Universal Links (from a backgrounded app)</h2>
<p>The testing process of universal links is very similar to the deeplinks' one, with an important difference: for some <i>god knows why</i> reason, universal links don't work in the simulator's Safari. It's unclear if that's on purpose or if it's really a bug, but while universal links work fine on your device's Safari, they will not work on the simulator's one.</p>
<p>This means we unfortunately can't use our Safari wrapper for them, but luckily you can still test universal links by using the <b>Messages</b> app. We can then test our universal links by opening the Messages app, clicking on a contact, sending them a universal link and tapping the newly sent message's link bubble to trigger it.</p>
<p>To launch Messages, we use the bundle identifier <code>"com.apple.MobileSMS"</code>.</p>
<pre><code>private func openFromMessages(_ urlString: String) {
let messages = XCUIApplication(bundleIdentifier: "com.apple.MobileSMS")
messages.launch()
XCTAssert(messages.wait(for: .runningForeground, timeout: 5))
// Dismiss "What's New" if needed
let continueButton = messages.buttons["Continue"]
if continueButton.exists {
continueButton.tap()
}
// Dismiss iOS 13's "New Messages" if needed
let cancelButton = messages.navigationBars.buttons["Cancel"]
if cancelButton.exists {
cancelButton.tap()
}
// Open the first available chat
let chat = messages.cells.firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 5))
chat.tap()
// Tap the text field
messages.textFields["iMessage"].tap()
// Dismiss Keyboard tutorial if needed
let keyboardTutorialButton = messages.buttons["Continue"]
if keyboardTutorialButton.exists {
keyboardTutorialButton.tap()
}
messages.typeText("Link: \(urlString)")
messages.buttons["sendButton"].tap()
let bubble = messages.links.firstMatch
XCTAssertTrue(bubble.waitForExistence(timeout: 5))
sleep(3)
bubble.tap()
}</code></pre>
<p>The logic to open a link from Messages is a little longer because it sometimes takes a couple more taps before being able to click our link. In this case, we may need to dismiss up to three 3 onboarding screens before being able to send a message. Additionally, before tapping the link, we <code>sleep(3)</code> to give iOS enough time to load our app's metadata. If you don't wait, sometimes iOS will fail to properly open your app.</p>
<p>The result, however, is the same from when we tested deeplinks in Safari. When you call this method, iOS will switch to Messages and attempt to switch back to your app via your universal link.</p>
<pre><code>func testUniversalLinkFromMessages() {
// Launch our app
let app = XCUIApplication()
app.launch()
// Launch Messages and univesal link back to our app
openFromMessages("https://swiftrocks.com/profile")
// Make sure Messages properly switched back to our app before asserting
XCTAssert(app.wait(for: .runningForeground, timeout: 5))
// Assert that the universal link worked by checking if we're in the "Profile" screen
XCTAssertTrue(app.navigationBars["Profile"].exists)
}</code></pre>
<h2>UI Testing Universal Links (that launches the app / from a killed app)</h2>
<div class="sponsor-article-ad-auto hidden"></div>
<p>Like with deeplinks, iOS's behavior differs slightly when launching your app as a result of tapping a universal link. When using SceneDelegates for example, you need to instead fetch them from a scene's <code>userActivities</code> property.</p>
<p>To confirm that our app can properly handle this, we can use the same trick we used for the deeplinks and terminate our app before executing the test.</p>
<pre><code>func testUniversalLinkFromMessages_fromBackgroundedApp() {
openMessagesUniversalLink(terminateFirst: false)
}
func testUniversalLinkFromMessages_thatLaunchesTheApp() {
openMessagesUniversalLink(terminateFirst: true)
}
func openMessagesUniversalLink(terminateFirst: Bool) {
let app = XCUIApplication()
app.launch()
if terminateFirst {
app.terminate()
}
// Launch Messages and univesal link back to our app
openFromMessages("https://swiftrocks.com/profile")
// Make sure Messages properly switched back to our app before asserting
XCTAssert(app.wait(for: .runningForeground, timeout: 5))
// Assert that the universal link worked by checking if we're in the "Profile" screen
XCTAssertTrue(app.navigationBars["Profile"].exists)
}</code></pre>
</div></div>
<div class="blog-post footer-main">
<div class="footer-logos">
<a href="https://swiftrocks.com/rss.xml"><i class="fa fa-rss"></i></a>
<a href="https://twitter.com/rockbruno_"><i class="fa fa-twitter"></i></a>
<a href="https://github.com/rockbruno"><i class="fa fa-github"></i></a>
</div>
<div class="footer-text">
© 2025 Bruno Rocha
</div>
<div class="footer-text">
<p><a href="https://swiftrocks.com">Home</a> / <a href="blog">See all posts</a></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Blog Post (Right Sidebar) End -->
</div>
</div>
</div>
<!-- All Javascript Plugins -->
<script type="text/javascript" src="js/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script type="text/javascript" src="js/prism4.js"></script>
<!-- Main Javascript File -->
<script type="text/javascript" src="js/scripts30.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-H8KZTWSQ1R"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-H8KZTWSQ1R');
</script>
</body>
</html>