How to mock UNNotificationResponse in unit tests

Issue #708

The best way to test is to not have to mock at all. The second best way is to have your own abstraction over the things you would like to test, either it is in form of protocol or some function injection.

But in case you want a quick way to test things, and want to test as real as possible, then for some cases we can be creative to mock the real objects.

One practical example is when we have some logic to handle notification, either showing or deep link user to certain screen. From iOS 10, notifications are to be delivered via UNUserNotificationCenterDelegate

1
2
3
4
5
@available(iOS 10.0, *)
public protocol UNUserNotificationCenterDelegate : NSObjectProtocol {
@available(iOS 10.0, *)
optional func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)
}

and all we get is UNNotificationResponse which has no real way to construct it.

1
2
3
4
5
6
@available(iOS 10.0, *)
open class UNNotificationResponse : NSObject, NSCopying, NSSecureCoding {


// The notification to which the user responded.
@NSCopying open var notification: UNNotification { get }

That class inherits from NSCopying which means it is constructed from NSCoder, but how do we init it?

1
let response = UNNotificationResponse(coder: ???)

NSObject and NSCoder

The trick is, since UNNotificationResponse is NSObject subclass, it is key value compliant, and since it is also NSCopying compliant, we can make a mock coder to construct it

1
2
3
4
private final class KeyedArchiver: NSKeyedArchiver {
override func decodeObject(forKey _: String) -> Any { "" }
override func decodeInt64(forKey key: String) -> Int64 { 0 }
}

On iOS 12, we need to add decodeInt64 method, otherwise UNNotificationResponse init fails. This is not needed on iOS 14

UNNotificationResponse has a read only UNNotification, which has a readonly UNNotificationRequest, which can be constructed from a UNNotificationContent

Luckily UNNotificationContent has a counterpart UNMutableNotificationContent

Now we can make a simple extension on UNNotificationResponse to quickly create that object in tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private extension UNNotificationResponse {
static func with(
userInfo: [AnyHashable: Any],
actionIdentifier: String = UNNotificationDefaultActionIdentifier
) throws -> UNNotificationResponse {
let content = UNMutableNotificationContent()
content.userInfo = userInfo
let request = UNNotificationRequest(
identifier: "",
content: content,
trigger: nil
)

let notification = try XCTUnwrap(UNNotification(coder: KeyedArchiver()))
notification.setValue(request, forKey: "request")

let response = try XCTUnwrap(UNNotificationResponse(coder: KeyedArchiver()))
response.setValue(notification, forKey: "notification")
response.setValue(actionIdentifier, forKey: "actionIdentifier")
return response
}
}

We can then test like normal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testResponse() throws {
let data: [AnyHashable: Any] = [
"data": [
"type": "OPEN_ARTICLE",
"articleId": "1",
"articleType": "Fiction",
"articleTag": "1"
]
]
let response = try UNNotificationResponse.with(userInfo: data)
let centerDelegate = ArticleCenterDelegate()
centerDelegate.userNotificationCenter(
UNUserNotificationCenter.current(),
didReceive: response,
withCompletionHandler: {}
)
XCTAssertEqual(response.notification.request.content.userInfo["type"], "OPEN_ARTICLE")
XCTAssertEqual(centerDelegate.didOpenArticle, true)
}

decodeObject for key

Another way is to build a proper KeyedArchiver that checks key and return correct property. Note that we can reuse the same NSKeyedArchiver to nested properties.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final class KeyedArchiver: NSKeyedArchiver {
let request: UNNotificationRequest
let actionIdentifier: String
let notification: UNNotification

override func decodeObject(forKey key: String) -> Any? {
switch key {
case "request":
return request
case "actionIdentifier":
return actionIdentifier
case "notification":
return UNNotification(coder: self)
default:
return nil
}
}
}

Updated at 2020-12-07 11:49:55

How to avoid multiple match elements in UITests from iOS 13

Issue #691

Supposed we want to present a ViewController, and there exist both UIToolbar in both the presenting and presented view controllers.

From iOS 13, the model style is not full screen and interactive. From UITests perspective there are 2 UIToolbar, we need to specify the correct one to avoid multiple match errors

1
let editButton = app.toolbars["EditArticle.Toolbar"].buttons["Edit"]

Updated at 2020-11-04 10:02:29

How to use accessibility container in UITests

Issue #690

Use accessibilityElements to specify containment for contentView and buttons. You can use Accessibility Inspector from Xcode to verify.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ArticleCell: UICollectionViewCell {
let authorLabel: UILabel
let dateLabel: UILabel

let viewLabel: UILabel
let deleteButton: UIButton

private func setupAccessibility() {
contentView.isAccessibilityElement = true
contentView.accessibilityLabel = "This article is written by Nobita on Dec 4th 2020"

viewLabel.isAccessibilityElement = true // Default is false
viewLabel.accessibilityTraits.insert(.button) // Treat UILabel as button to VoiceOver

accessibilityElements = [contentView, viewLabel, deleteButton]
isAccessibilityElement = false
}
}

This works OK under Voice Over and Accessibility Inspector. However in iOS 14 UITests, only the contentView is recognizable. The workaround is to use XCUICoordinate

1
2
3
4
5
6
7
8
9
let deleteButton = cell.buttons["Delete article"]
if deleteButton.waitForExistence(timeout: 1) {
deleteButton.tap()
} else {
let coordinate = cell.coordinate(
withNormalizedOffset: CGVector(dx: 0.9, dy: 0.9)
)
coordinate.tap()
}

Updated at 2020-11-04 09:42:05

How to test DispatchQueue in Swift

Issue #646

Sync the DispatchQueue

Pass DispatchQueue and call queue.sync to sync all async works before asserting

Use mock

Use DispatchQueueType and in mock, call the work immediately

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Foundation

public protocol DispatchQueueType {
func async(execute work: @escaping @convention(block) () -> Void)
}

extension DispatchQueue: DispatchQueueType {
public func async(execute work: @escaping @convention(block) () -> Void) {
async(group: nil, qos: .unspecified, flags: [], execute: work)
}
}

final class MockDispatchQueue: DispatchQueueType {
func async(execute work: @escaping @convention(block) () -> Void) {
work()
}
}

How to assert asynchronously in XCTest

Issue #644

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import XCTest

extension XCTestCase {
/// Asynchronously assertion
func XCTAssertWait(
timeout: TimeInterval = 1,
_ expression: @escaping () -> Void,
_: String = "",
file _: StaticString = #file,
line _: UInt = #line
) {
let expectation = self.expectation(description: #function)
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
expression()
expectation.fulfill()
}

let waiter = XCTWaiter()
XCTAssertTrue(waiter.wait(for: [expectation], timeout: timeout + 1) == .completed)
}
}

Updated at 2020-04-28 09:23:59

How to iterate over XCUIElementQuery in UITests

Issue #628

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
extension XCUIElementQuery: Sequence {
public typealias Iterator = AnyIterator<XCUIElement>
public func makeIterator() -> Iterator {
var index = UInt(0)
return AnyIterator {
guard index < self.count else { return nil }

let element = self.element(boundBy: Int(index))
index = index + 1
return element
}
}
}

extension NSPredicate {
static func label(contains string: String) -> NSPredicate {
NSPredicate(format: "label CONTAINS %@", string)
}
}

let books = app.collectionViews.cells.matching(
NSPredicate.label(contains: "book")
)

for book in books {

}

How to test drag and drop in UITests

Issue #583

In UITests, we can use press from XCUIElement to test drag and drop

1
2
3
4
5
let fromCat = app.buttons["cat1"].firstMatch
let toCat = app.buttons["cat2"]
let fromCoordinate = fromCat.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
let toCoordinate = toCat.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: -0.5))
fromCoordinate.press(forDuration: 1, thenDragTo: toCoordinate)

and then take screenshot

1
2
3
4
5
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
attachment.name = name
add(attachment)

Screenshot capturing happens after the action, so it may be too late. One way is to inject launch arguments, like app.launchArguments.append("--dragdrop") to alter some code in the app.

We can also swizzle gesture recognizer to alter behavior

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension UILongPressGestureRecognizer {
@objc var uiTests_state: UIGestureRecognizer.State {
let state = self.uiTests_state
if state == .ended {
return .changed
} else {
return state
}
}
}

let originalSelector = #selector(getter: UILongPressGestureRecognizer.state)
let swizzledSelector = #selector(getter: UILongPressGestureRecognizer.uiTests_state)

let originalMethod = class_getInstanceMethod(UILongPressGestureRecognizer.self, originalSelector)!
let swizzledMethod = class_getInstanceMethod(UILongPressGestureRecognizer.self, swizzledSelector)!

let didAddMethod = class_addMethod(UILongPressGestureRecognizer.self, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod))

if didAddMethod {
class_replaceMethod(UILongPressGestureRecognizer.self, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod))
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}

How to generate XCTest test methods

Issue #576

Code

See Spek

Override testInvocations to specify test methods

https://developer.apple.com/documentation/xctest/xctestcase/1496271-testinvocations

Returns an array of invocations representing each test method in the test case.

Because testInvocations is unavailable in Swift, we need to use ObjC

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
#import "include/SpekHelperTestCase.h"

@implementation SpekHelperTestCase

- (instancetype)init {
self = [super initWithInvocation: nil];
return self;
}

+ (NSArray<NSInvocation *> *)testInvocations {
NSArray<NSString *> *selectorStrings = [self spekGenerateTestMethodNames];
NSMutableArray<NSInvocation *> *invocations = [NSMutableArray arrayWithCapacity:selectorStrings.count];

for (NSString *selectorString in selectorStrings) {
SEL selector = NSSelectorFromString(selectorString);
NSMethodSignature *signature = [self instanceMethodSignatureForSelector:selector];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
invocation.selector = selector;

[invocations addObject:invocation];
}

return invocations;
}

+ (NSArray<NSString *> *)spekGenerateTestMethodNames {
return @[];
}

@end

Generate test methods

Calculate based on Describe and It, and use Objc runtime class_addMethod to add instance methods

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
open class SpekTestCase: SpekHelperTestCase {
open class func makeDescribe() -> Describe {
return Describe("empty")
}

#if canImport(SpekHelper)

override public class func spekGenerateTestMethodNames() -> [String] {
let describe = Self.makeDescribe()

var names: [String] = []
generate(describe: describe, names: &names)
return names
}

private static func addInstanceMethod(name: String, closure: @escaping () -> Void) -> String {
let block: @convention(block) (SpekTestCase) -> Void = { spekTestCase in
let _ = spekTestCase
closure()
}

let implementation = imp_implementationWithBlock(block as Any)
let selector = NSSelectorFromString(name)
class_addMethod(self, selector, implementation, "v@:")

return name
}
}

Read more

How to use passed launch arguments in UITests

Issue #537

Specify launch arguments

In xcodebuild, specify launch arguments.

You can specify this under Launch Arguments in Run action of the app scheme or UITest scheme

Screenshot 2019-12-10 at 23 27 02
1
-AppleLanguages (jp) -AppleLocale (jp_JP)
1
2
3
4
5
6
7
8
9
10
11
12
13
(lldb) po ProcessInfo().arguments
11 elements
- 0 : "/Users/khoa/Library/Developer/CoreSimulator/Devices/561F2B45-26B2-4897-98C4-8A917AEB48D2/data/Containers/Bundle/Application/436E0A43-8323-4F53-BBE0-6F75F674916F/TestAppUITests-Runner.app/TestAppUITests-Runner"
- 1 : "-AppleLanguages"
- 2 : "(ja)"
- 3 : "-AppleTextDirection"
- 4 : "NO"
- 5 : "-AppleLocale"
- 6 : "ja_JP"
- 7 : "-NSTreatUnknownArgumentsAsOpen"
- 8 : "NO"
- 9 : "-ApplePersistenceIgnoreState"
- 10 : "YES"

In UITests, pass launch arguments from UITest scheme to UITest application

1
app.launchArguments += ProcessInfo().arguments

Environments

1
ProcessInfo().environment // [String: String]

How to add monkey test to iOS apps

Issue #484

Use SwiftMonkey which adds random UITests gestures

Add to UITests target

1
2
3
4
target 'MyAppUITests' do
pod 'R.swift', '~> 5.0'
pod 'SwiftMonkey', '~> 2.1.0'
end

Troubleshooting

Failed to determine hittability of Button

Failed to determine hittability of Button: Unable to fetch parameterized attribute XC_kAXXCParameterizedAttributeConvertHostedViewPositionFromContext, remote interface does not have this capability.

This happens when using SwiftMonkey and somewhere in our code uses isHittable, so best to avoid that by having isolated monkey test only

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import XCTest
import SwiftMonkey

class MonkeyTests: XCTestCase {
var app: XCUIApplication!

override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

func testMonkey() {
let monkey = Monkey(frame: app.frame)
monkey.addDefaultUIAutomationActions()
monkey.addXCTestTapAlertAction(interval: 100, application: app)
monkey.monkeyAround()
}
}

Another workaround is possibly use addDefaultXCTestPublicActions other than addDefaultUIAutomationActions

UI Test Activity:

Assertion Failure: MonkeyXCTest.swift:33: Failed to get matching snapshots: Timed out while evaluating UI query.

This seems related to SwiftMonkey trying to snapshot. Workaround is to remove

1
monkey.addXCTestTapAlertAction(interval: 100, application: app)

How to configure test target in Xcode

Issue #478

This applies to

  • Main targets
    • App
    • Framework
  • Test targets
    • Unit tests
    • UI tests

Examples

Dependencies used

Examples

  • Cocoapods
  • Carthage

Notes

  • Make sure test target can link to all the frameworks it needs. This includes frameworks that Test targets use, and possibly frameworks that Main target uses !
  • Remember to “Clean Build Folder” and “Clear Derived Data” so that you’re sure it works. Sometimes Xcode caches.

Errors

Errors occur mostly due to linker error

  • Test target X encountered an error (Early unexpected exit, operation never finished bootstrapping - no restart will be attempted
  • Framework not found

Cocoapods

1. Pod

Test targets need to include pods that Main target uses !

or we’ll get “Framework not found”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def app_pods
pod 'Sugar', '~> 1.0'
end

def test_pods
pod 'Nimble', '~> 3.2'
pod 'Quick', '~> 0.9'
end

target 'TeaApp' do
app_pods
end

target 'TeaAppTests' do
app_pods
test_pods
end

target 'TeaAppUITests' do
app_pods
test_pods
end

Cocoapods builds a framework that contains all the frameworks the Test targets need, and configure it for us

3. Runpath Search Paths

  • Go to Test target Build Settings
  • Add $(FRAMEWORK_SEARCH_PATHS)

Carthage

1. Cartfile

We usually have

  • Cartfile for Main target
1
github "hyperoslo/Sugar" ~> 1.0
  • Cartfile.private for Test target
1
2
github "Quick/Nimble"
github "Quick/Quick"
  • Go to Test target build phase
  • Drag built frameworks from Carthage/Build
  • In rare case, we need to drag frameworks that the Main target uses
  • In rare case, we need to drag the Main target framework

3. Framework Search Paths

Configure correct path

  • Go to Test target Built Settings
  • Configure Framework Search Paths

4. Runpath Search Paths

  • Go to Test target Build Settings
  • Add $(FRAMEWORK_SEARCH_PATHS)

5. Copy Files (maybe)

From Adding frameworks to unit tests or a framework

In rare cases, you may want to also copy each dependency into the build product (e.g., to embed dependencies within the outer framework, or make sure dependencies are present in a test bundle). To do this, create a new “Copy Files” build phase with the “Frameworks” destination, then add the framework reference there as well.

Runpath Search Paths and Install name

Question

  • Why preconfigured run path “@executable_path/Frameworks” and “@loader_path/Frameworks” not work?
  • Why configuring runpath to “$(FRAMEWORK_SEARCH_PATHS)” works ?
  • Why framework has install name “@rpath/Sugar.framework/Sugar” ?

Reference

Code

How to test Date with timezone aware in Swift

Issue #402

I want to test if a date has passed another date

1
2
let base =  Date(timeIntervalSince1970: 1567756697)
XCTAssertEqual(validator.hasPassed(event: event, date: base), true)

My hasPassed is using Calendar.current

1
2
3
4
5
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
return Int(date.timeIntervalSince(start)) / 60
}

But the minute is always having timezone applied. Even if I try with DateComponents

1
2
3
4
5
6
7
8
func minuteSinceMidnight(date: Date) -> MinuteSinceMidnight {
let components = calendar.dateComponents([.hour, .minute], from: date)
guard let hour = components.hour, let minute = components.minute else {
return 0
}

return hour * 60 + minute
}

As long as I use Calendar, it always has timezone applied.

Checking this time interval 1567756697 on https://www.epochconverter.com/

Assuming that this timestamp is in seconds:
GMT: Friday, September 6, 2019 7:58:17 PM
Your time zone: Friday, September 6, 2019 9:58:17 PM GMT+02:00 DST

Because I have GMT+2, there will always be 2 hours offset. This works in app, but not in test because of the way I construct Date with time interval.

One way is to have test data using string construction, and provide timezone to DateFormatter

1
2
3
let formatter = ISO8601DateFormatter()
let date = formatter.date(from: "2019-07-58T12:39:00Z")
let string = formatter.string(from: Date())

Another way is to have a fixed timezone for Calendar

1
2
var calendar = Calendar.current
calendar.timeZone = TimeZone(secondsFromGMT: 0)!

Another way is to adjust existing date

1
calendar.date(bySettingHour: 20, minute: 02, second: 00, of: Date()

How to organise test files

Issue #327

In terms of tests, we usually have files for unit test, UI test, integeration test and mock.

Out of sight, out of mind.

Unit tests are for checking specific functions and classes, it’s more convenient to browse them side by side with source file. For example in Javascript, Kotlin and Swift

1
2
3
index.js
index.test.js
index.mock.js
1
2
3
LocationManager.kt
LocationManager.mock.kt
LocationManager.test.kt
1
2
3
BasketHandler.swift
BasketHandler.mock.swift
BasketHandler.test.swift

Integration tests check features or sub features, and may cover many source files, it’s better to place them in feature folders

1
2
3
4
5
6
7
8
9
10
11
- Features
- Cart
- Sources
- Tests
- Cart.test.swift
- Validator.test.swift
- Profile
- Sources
- Tests
- Updater.test.swift
- AvatarUploader.test.swift

How to test LaunchScreen in iOS

Issue #249

Making splash screen with LaunchScreen.storyboard is now the default way to do in iOS. Testing it with UITests is a bit tricky as this screen is showed the system, and if we test that, we are just testing the system.

What we should test is the content we put in the LaunchScreen storyboard. Is it showing correctly on different screen sizes? Is it missing any texts or images?

One way to test that is via Unit Test. LaunchScreen storyboard always come with 1 UIViewController configured as an initial view controller

1
2
3
4
5
6
7
8
9
10
11
12
class LauncScreenTests: XCTestCase {
func testLaunchScreen() {
let launchScreen = UIStoryboard(name: "LaunchScreen", bundle: nil)
let viewController = launchScreen.instantiateInitialViewController()!

let label = viewController.view.subviews.compactMap({ $0 as? UILabel }).first!
XCTAssertEqual(label.text, "Welcome to my app")

let imageView = viewController.view.subviews.compactMap({ $0 as? UIImageView }).first!
XCTAssertNotNil(imageView.image)
}
}

How to deal with animation in UITests in iOS

Issue #143

Today I was writing tests and get this error related to app idle

1
t =    23.06s         Assertion Failure: <unknown>:0: Failed to scroll to visible (by AX action) Button, 0x6000003827d0, traits: 8858370049, label: 'cart', error: Error -25204 performing AXAction 2003 on element <XCAccessibilityElement: 0x7fc391a2bd60> pid: 91461, elementOrHash.elementID: 140658975676048.128

It turns out that the project uses a HUD that is performing some progress animation. Even it was being called HUD.hide(), the problem still exists.

1
2
3
4
t =    31.55s     Wait for no.example.MyApp to idle
t = 91.69s App animations complete notification not received, will attempt to continue.
t = 91.70s Tap Target Application 0x6040002a1260
t = 91.70s Wait for no.example.MyApp to id

No matter how I call sleep,wait`, still the problem

1
2
3
sleep(10)
app.tap()
_ = checkoutButton.waitForExistence(timeout: 10)

The fix is to disable animation. Start with setting argument when running tests

1
2
app.launchArguments.append("--UITests")
app.launch

Then in AppDelegate

1
2
3
4
5
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
if CommandLine.arguments.contains("--UITests") {
UIView.setAnimationsEnabled(false)
}
}

How to use Given When Then in Swift tests

Issue #73

Spec

Using spec testing framework like Quick is nice, which enables BDD style.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe("the 'Documentation' directory") {
it("has everything you need to get started") {
let sections = Directory("Documentation").sections
expect(sections).to(contain("Organized Tests with Quick Examples and Example Groups"))
expect(sections).to(contain("Installing Quick"))
}

context("if it doesn't have what you're looking for") {
it("needs to be updated") {
let you = You(awesome: true)
expect{you.submittedAnIssue}.toEventually(beTruthy())
}
}
}

But in case you don’t want additional frameworks, and want to live closer to Apple SDKs as much as possible, here are few tips.

Naming

This is from the book that I really like The Art of Unit Testing. If you don’t mind the underscore, you can follow UnitOfWork_StateUnderTest_ExpectedBehavior structure

1
2
3
func testSum_NegativeNumberAs1stParam_ExceptionThrown()
func testSum_NegativeNumberAs2ndParam_ExceptionThrown()
func testSum_simpleValues_Calculated()

Given When Then

This is from BDD, and practised a lot in Cucumber. You can read more on https://martinfowler.com/bliki/GivenWhenThen.html.

First, add some more extensions to XCTestCase

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import XCTest

extension XCTestCase {
func given(_ description: String, closure: () throws -> Void) throws {
try closure()
}

func when(_ description: String, closure: () throws -> Void) throws {
try closure()
}

func then(_ description: String, closure: () throws -> Void) throws {
try closure()
}
}

Then, in order to test, just follow given when then

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testRemoveObject() throws {
try given("set to storage") {
try storage.setObject(testObject, forKey: key)
}

try when("remove object from storage") {
try storage.removeObject(forKey: key)
}

try then("there is no object in memory") {
let memoryObject = try? storage.memoryCache.object(forKey: key) as User
XCTAssertNil(memoryObject)
}

try then("there is no object on disk") {
let diskObject = try? storage.diskCache.object(forKey: key) as User
XCTAssertNil(diskObject)
}
}

I find this more interesting than comments. All are code and descriptive. It can also be developed further to throw the description text.


Updated at 2020-12-18 15:15:26

How to run UI Test with system alert in iOS

Issue #48

Continue my post https://github.com/onmyway133/blog/issues/45. When you work with features, like map view, you mostly need permissions, and in UITests you need to test for system alerts.

Add interruption monitor

This is the code. Note that you need to call app.tap() to interact with the app again, in order for interruption monitor to work

1
2
3
4
5
6
addUIInterruptionMonitor(withDescription: "Location permission", handler: { alert in
alert.buttons["Allow"].tap()
return true
})

app.tap()

Note that you don’t always need to handle the returned value of addUIInterruptionMonitor

Only tap when needed

One problem with this approach is that when there is no system alert (you already touched to allow before), then app.tap() will tap on your main screen. In my app which uses map view, it will tap on some pins, which will present another screen, which is not correct.

Since app.alerts does not work, my 2nd attempt is to check for app.windows.count. Unfortunately, it always shows 5 windows whether alert is showing or not. I know 1 is for main window, 1 is for status bar, the other 3 windows I have no idea.

The 3rd attempt is to check that underlying elements (behind alert) can’t be touched, which is to use isHittable. This property does not work, it always returns true

Check the content

This uses the assumption that we only tests for when user hits Allow button. So only if alert is answered with Allow, then we have permission to display our content. For my map view, I check that there are some pins on the map. See https://github.com/onmyway133/blog/issues/45 on how to mock location and identify the pins

1
2
3
if app.otherElements.matching(identifier: "myPin").count == 0 {
app.tap()
}

When there is no permission

So how can we test that user has denied your request? In my map view, if user does not allow location permission, I show a popup asking user to go to Settings and change it, otherwise, they can’t interact with the map.

I don’t know how to toggle location in Privacy in Settings, maybe XCUISiriService can help. But 1 thing we can do is to mock the application

Before you launch the app in UITests, add some arguments

1
app.launchArguments.append("--UITests-mockNoLocationPermission")

and in the app, we need to check for this arguments

1
2
3
4
5
func checkLocationPermission() {
if CommandLine.arguments.contains("--UITests-mockNoLocationPermission") {
showNoLocationPopupAndAskUserToEnableInSettings()
}
}

That’s it. In UITests, we can test whether that no location permission popup appears or not

Updated at 2020-06-02 02:12:36