Test for UITableViewCell existence failing from inside a split view controller - ios

I am testing the existence of a table view cell and the following code works perfectly fine on an iPhone 7:
let complaintCell = self.app.tables.cells.element(boundBy: 0)
XCTAssert(complaintCell.exists)
complaintCell.tap()
Now the problem is that if I run the same test on an iPad where the view controller is embedded inside a split view controller, the test fails:
The table view is still visible on the master view controller:
So I can't find why the test fails, even if the table view is the only visible one. Any hint?
Full code:
func testNavigation() {
let complaintCell = self.app.tables.cells.element(boundBy: 0)
XCTAssert(complaintCell.exists)
complaintCell.tap()
XCTAssert(self.app.navigationBars["Complaint #100"].exists)
XCTAssertFalse(self.app.navigationBars["Complaint #99"].exists)
let editButton = self.app.buttons["editComplaint"]
XCTAssert(editButton.exists)
editButton.tap()
XCTAssert(self.app.navigationBars["Complaint #100"].exists)
XCTAssertFalse(self.app.navigationBars["Complaint #99"].exists)
let saveButton = self.app.buttons["Save"]
XCTAssert(saveButton.exists)
saveButton.tap()
let okButton = self.app.buttons["Ok"]
XCTAssert(okButton.exists)
okButton.tap()
}
Update
I was able to isolate the problem: if I just create a new project, a master detail application and I set the accessibility identifier of the main table view, and then I test for its existence, the test fails:
let table = self.app.tables["TableView"]
XCTAssert(table.waitForExistence(timeout: 5.0))
The steps necessary in order to reproduce this problem are very simple, you just need to create a master detail application, set the accessibility identifier of the table view and then run the above code. But if you want you can also clone this repository, which I used as a demo to isolate the problem: https://github.com/ralzuhouri/tableViewTestDemo

I encountered the same problem when trying to test for tableview cell existence on the simulator. On a simulated iPhone device the test would succeed, whereas on an iPad device it would fail.
I found that the problem lay in the fact that a UITest that references a table will fail if the app's current view does not have the tableview whose data you would like to test. On an iPhone the view by default had a back button that would transition the app from its detail view back to the master viewcontroller which contained the tableview. On the iPad simulator this back button was not there, and so it could not transition correctly to the tableview's view, making the entire test fail.
func testTableCellsExist() {
let app = XCUIApplication()
app.launch()
app.navigationBars["AppName.DetailView"].buttons["Root View Controller"].tap()
let tablesQuery = app.tables["MasterTable"]
let testCell = tablesQuery.cells.element(boundBy: 49)
XCTAssert(tablesQuery.cells.count == 50)
XCTAssert(testCell.exists)
}
What I did to make the test succeed for both iPad and iPhone device simulations was to make it so that the app would launch and display the tableview's viewcontroller at the outset. This was done by adding this code to the UISplitViewController swift file:
class SplitViewController: UISplitViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
self.preferredDisplayMode = .allVisible
}
}
extension SplitViewController: UISplitViewControllerDelegate {
func splitViewController(
_ splitViewController: UISplitViewController,
collapseSecondary secondaryViewController: UIViewController,
onto primaryViewController: UIViewController) -> Bool {
// Return true to prevent UIKit from applying its default behavior
return true
}
}
The explanation for the above code can be found here:
Open UISplitViewController to Master View rather than Detail
Regardless, after the above modification, the assertions for tableview cell existence should now succeed even on an iPad because the view is now correctly set to the view which has the tableview being queried.
If you don't want your app to start with this view by default, then you'll have to make sure that your test code transitions to the tableview's view before the assertions are made.
Also, if you follow my example through, be sure to remove this line in the test case, because it no longer becomes necessary to navigate to the tableview's VC after the modifications to the UISplitViewController code:
app.navigationBars["AppName.DetailView"].buttons["Root View Controller"].tap()
UPDATE (October 25): Master Detail App Project - Basic TableView Existence Test
I attempted to create a basic Master Detail app as you suggested, and tried the test as well. A basic test for the tableview failed again with me when I selected an iPad device to simulate, because it shows only the detail view (no table). I modified my test so that if the device is an iPad, it will check its orientation, and set the landscape orientation as required, before checking for the table's existence. I only modified the test and nothing else, and what was previously a failure became a success. I also set the accesibility identifier for the tableview in the MasterVC's viewDidLoad, but I believe the results would be the same whether we set the identifier or not. Here's the test code:
func testExample() {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use recording to get started writing UI tests.
// Use XCTAssert and related functions to verify your tests produce the correct results.
if UIDevice.current.userInterfaceIdiom == .pad {
print("TESTING AN IPAD\n")
print(XCUIDevice.shared.orientation.rawValue)
// For some reason isPortrait returns false
// If rawValue == 0 it also seems to indicate iPad in portrait mode
if (XCUIDevice.shared.orientation.isPortrait || XCUIDevice.shared.orientation.rawValue == 0){
XCUIDevice.shared.orientation = .landscapeRight
}
XCTAssert(app.tables["MyTable"].exists)
//XCTAssert(app.tables["MyTable"].waitForExistence(timeout: 5.0))
} else if UIDevice.current.userInterfaceIdiom == .phone {
print("TESTING AN IPHONE\n")
XCTAssert(app.tables["MyTable"].exists)
}
// XCTAssert(app.tables["MyTable"].exists)
}
I added an else for the iPhone case, and print logs to say which type of device is being tested. Whether .exists or .waitForExistence is used, the tests should always succeed. But as was pointed out, .waitForExistence is better in the case where the table view takes time to load up.
Hope this helps. Cheers!
UPDATE (OCTOBER 27): Test Fails on a Real iPad
OP has discerned that the test -- which succeeds for simulated iPhone and iPad devices -- fails on a real device (for more information, please check his informative comment for this answer below). As the test failed on a real iPad which was already in landscape mode (see screenshot), it may be assumed that XC UITest functionality is broken in this regard.
In any case I hope these tests and results will prove helpful (they certainly taught me a thing or two) to others as well.
Cheers :D

Your problem seems to be with the orientation of the device as I noticed when trying it on your test project. I have managed to come up with two solutions, both work perfectly choose between 1 or 1a as per comments in code. Option 1 will work on both iPhones and iPads option 1a is iPad specific or the larger iPhones which display the master in landscape as well.
First set the tableViews accessibility identifier in your code to easily reference the correct tableView:
complaintTableView.accessibilityIdentifier = "ComplaintsTableView"
Then in your tests just reference the identifier - here is the full test according to your question above:
func testNavigation() {
// 1 - Hit the back button on the collapset master view
let masterButton = app.navigationBars.buttons.element(boundBy: 0)
if masterButton.exists {
masterButton.tap()
print("BACK TAPPED")
}
// 1a - Or just use the line below to rotate the view to landscape which will automatically show the master view
XCUIDevice.shared.orientation = UIDeviceOrientation.landscapeRight
let complaintsTable = app.tables["ComplaintsTableView"]
XCTAssertTrue(complaintsTable.exists, "Table does not exist")
let complaintCell = complaintsTable.cells.firstMatch
XCTAssert(complaintCell.exists)
complaintCell.tap()
XCTAssert(self.app.navigationBars["Complaint #100"].exists)
XCTAssertFalse(self.app.navigationBars["Complaint #99"].exists)
let editButton = self.app.buttons["editComplaint"]
XCTAssert(editButton.exists)
editButton.tap()
XCTAssert(self.app.navigationBars["Complaint #100"].exists)
XCTAssertFalse(self.app.navigationBars["Complaint #99"].exists)
let saveButton = self.app.buttons["Save"]
XCTAssert(saveButton.exists)
saveButton.tap()
let okButton = self.app.buttons["Ok"]
XCTAssert(okButton.exists)
okButton.tap()
}

You shall probably use waitForExistence() instead of .exists
.exists executes as soon as it is called. You TableView may not be prepared to be tested at this moment.
I would try to replace .exists with waitForExistence()
let complaintCell = self.app.tables.cells.element(boundBy: 0)
XCTAssert(complaintCell.waitForExistence(timeout: 10))
complaintCell.tap()
Also you can ditch this XCTAssert.
tap() will wait for existence for 3 seconds and then produce an error message, if the cell does not exist.
The shortened version will be:
app.tables.cells.firstMatch.tap()

I've downloaded your demo.
While iPhone app start with Master View, the iPad app goes with SplitView or Detail View (in portrait orientation).
Portrait View should start with Master + Detail View. Other option to change UI tests like this:
func testExample() {
let table = self.app.tables["TableView"]
if !table.waitForExistence(timeout: 1) {
app.navigationBars.element.swipeRight()
XCTAssert(table.waitForExistence(timeout: 2))
}
...
}

Related

UISheetPresentationController displaying differently on different phones

I created a UIStoryboardSegue to make a "Bottom Sheet segue". Our designer shared a screenshot of the app on his phone and the bottom sheet is displaying differently, despite the fact we are both on the same iOS version.
On mine and my simulator, when the bottom sheet opens, it lightens the source view and then shrinks it down a little, so it appears just barely behind the bottom sheet
On the same screen on the designers device, it dims the background and leaves the source view full size, showing the top of the buttons in the navigation bar
I've noticed the Apple maps bottom sheet behaves like the designers, no shrinking of the background view. But I can't see any settings that would affect this. How can I stop the sheet from resizing the source view on mine and function like it's supposed to?
Here's my code:
import UIKit
public class BottomSheetLargeSegue: UIStoryboardSegue {
override public func perform() {
guard let dest = destination.presentationController as? UISheetPresentationController else {
return
}
dest.detents = [.large()]
dest.prefersGrabberVisible = true
dest.preferredCornerRadius = 30
source.present(destination, animated: true)
}
}
Found a hack to force it to never minimise the source view at least, not really what I wanted, but keeps it consistent. Supposedly, .large() is always supposed to minimize the source view, you can avoid this in iOS 16 by creating a custom detent that is just a tiny bit smaller than large like so:
let customId = UISheetPresentationController.Detent.Identifier("large-minus-background-effect")
let customDetent = UISheetPresentationController.Detent.custom(identifier: customId) { context in
return context.maximumDetentValue - 0.1
}
dest.detents = [customDetent]
And as a bonus, found a way to control the dimming on the bottom sheet overlay. There is a containerView property on the presentationController, but it is nil when trying to access it in while in the segue. If you force code to run on the main thread, after the call to present, you can access the containerView and set your own color / color animation
e.g.
...
...
source.present(destination, animated: true)
DispatchQueue.main.async {
dest.containerView?.backgroundColor = .white
}

Unit test, to test if a view controller has presented another view controller

I want to make a unit test to see if a view controller is presenting another view controller.
func testMainTabController_WhenActionButtonIsTapped_NewSuppliersInvoiceControllerIsCreatedV2() {
let sut = MainTabBarController()
sut.loadViewIfNeeded()
let myExpectation = expectation(description: "The sut should present a view controller")
sut.actionButton.sendActions(for: .touchUpInside)
if let x = sut.presentedViewController {
myExpectation.fulfill()
} else {
XCTFail("The view controller was not presented")
}
wait(for: [myExpectation], timeout: 3)
}
Here the result I obtain is fail test. Because I get nil as a presentedViewController. This is the code of the button
#objc func handleActionButtonTap() {
let suppliersInvoiceController = SuppliersInvoiceController()
let navCon = UINavigationController(rootViewController: suppliersInvoiceController)
navCon.modalPresentationStyle = .fullScreen
present(navCon, animated: true, completion: nil)
}
This is the code I wrote in the test. The code in the button is successfully called when I run the unit test and the present method is called. Of course if I run the app it works properly.
When I tap the button, I get my presented view controller.
If I type let vc = presentedViewController inside handleActionButtonTap() and print(vc) I get the nav con as a result. But why can't I do it inside the unit test?
Does anybody has a clue of whats going on?
Thanks
What you want is to do UITest and not UnitTest.
What is a Unit Test? https://x-team.com/blog/how-to-get-started-with-ios-unit-tests-in-swift/
A unit test is a function you write that tests something about your
app. A good unit test is small. It tests just one thing in isolation.
For example, if your app adds up the total amount of time your user
spent doing something, you might write a test to check if this total
is correct.
So going back to the answer, do UI Testing for that. Here's a sample cheat sheet for doing UI Tests after a google search: https://github.com/joemasilotti/UI-Testing-Cheat-Sheet
In UI Testing, you will be able to check if a screen was pushed or presented after tapping on a button or something, like for example:
XCTAssert(app.staticTexts["Some Static String From SuppliersInvoiceController "].exists)
Does you viewController have enough context for presentViewController:animated: to work? for example, it has no window. PresentViewController:animated: does different things in "horizontally regular environment" etc.
If you want to unit test, how about changing the VC to not present other VC's directly from itself, instead have it call a method on e.g. a coordinator object that you can mock in, then assert that the mock's version of 'present:animated:' gets called as you would expect.

Supported Interface Orientations in info.plist seem to block calls to shouldAutorotate

I’m designing an iPad/iPhone application in which the interface is designed to look as good in landscape as in portrait, and I’d like the interface to appear at launch as gracefully in one orientation as the other. At the same time, I need the ability to enable/disable autorotations at various times. I think I know how to do both these things, but they don’t seem to play nicely together.
I can set permitted device orientations in the Xcode project, and this seems to create a couple of array items in the project info.plist: "Supported interface orientations (iPad)" and "Supported interface orientations (iPhone)" which list the allowed orientations for each type of device. The presence of these items makes transitions from the launch screen as smooth as silk in all orientations.
I can enable/disable autorotations by overriding shouldAutorotate in my root view controller (there is only one controller at the moment). The problem is that the presence of Supported Interface items in info.plist seem to suppress all calls to shouldAutorotate, thus rendering control of autorotation inoperative.
class RootController: UIViewController
{
var allowAutorotation = true // My autorotation switch.
{ didSet { guard allowAutorotation else { return }
UIViewController.attemptRotationToDeviceOrientation()
/* Do other stuff. */ } }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask
{ NSLog ("RootController: supportedInterfaceOrientations")
return .allButUpsideDown }
override var shouldAutorotate: Bool
{ NSLog ("RootController: shouldAutorotate")
return allowAutorotation }
override func viewDidLoad()
{ allowAutorotation = true
super.viewDidLoad() }
}
I’m using viewDidLoad to call attemptRotationToDeviceOrientation() at the earliest possible moment. If I don’t do this, the app screen appears in Portrait even when the device is in Landscape. (It appears correctly for Portrait, but Portrait isn’t correct.) I’ve tried making a similar call from other places, including didFinishLaunchingWithOptions in the app delegate, which I think may be the most logical place for it, but it doesn’t seem to make a difference.
If I remove the Supported Orientation items from info.plist, and define allowed orientations in code as shown, I can see from my NSLog telltales that calls to shouldAutorotate do occur at appropriate moments, but the launch then looks a trifle awkward in landscape. In the launch screen, the status bar is oriented wrong: along one of the vertical edges rather than at top, and when the transition to the app screen comes, it typically fades in canted about 10-20° off of horizontal. It instantly snaps to horizontal (it’s so quick, in fact, that it’s sometimes difficult to see), and the status bar is then correctly at the top, and from that moment on everything looks good, but the effect still seems a little unprofessional.
Since it only occurs momentarily, and only once at launch, I suppose I could live with it (I can’t live without an autorotation control) but I was hoping someone could suggest a way to get shouldAutorotate calls to work even with orientations defined in info.plist. Or perhaps some other strategy?
I think I have workarounds for these problems. To eliminate the anomalous rotation animation as the Launch Screen fades out, I use CATransactions to disable implicit animations until the app becomes active for the first time:
class RootController: UIViewController {
private var appIsLaunching = true // Set false when app first becomes active. Never set true again.
func appDidBecomeActive()
{ if appIsLaunching { appIsLaunching = false; CATransaction.setDisableActions (false) } }
override func viewWillTransition (to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
{ if appIsLaunching { CATransaction.setDisableActions (true) }
super.viewWillTransition (to: size, with: coordinator) }
}
And in the appDelegate:
func applicationDidBecomeActive ( _ application: UIApplication )
{ let root = window!.rootViewController as! RootController; root.appDidBecomeActive() }
I believe this works because a default CATransaction is always in effect, even without an explicit transaction. The cross-dissolve from Launch Screen to first view seems perfect.
There remains the status bar appearing in the wrong orientation in the Launch Screen. I’ve concluded it’s best to just turn it off, either in the project settings or by setting Status bar is initially hidden in info.plist. Not ideal, but acceptable.

Detect when UISplitViewController changes display mode

I'm trying to use a UISplitViewController where the secondary controller should expose a "close" function (via a button or button bar item) whenever the UISplitViewController is in side-by-side mode, but should hide the function at other times. I tried putting this in the secondary view controller:
override func viewWillAppear(_ animated: Bool) {
if splitViewController!.primaryHidden {
// hide the "close" UI artifact
} else {
// show the "close" UI artifact
}
}
This correctly sets the visibility of the "close" function when the secondary view is first displayed, but if the UISplitViewController switches between expanded and collapsed (say, by rotating an iPhone 6s Plus), then this function is not called again (which makes sense, as the secondary controller remains visible). Consequently, the "close" function remains in its initial state--hidden or shown--even as the UISplitViewController changes mode.
How can I get the "close" function to hide or show in reaction to changes in the mode of the UISplitViewController?
There is the UIViewControllerShowDetailTargetDidChangeNotification notification for that:
// Sometimes view controllers that are using showViewController:sender and
// showDetailViewController:sender: will need to know when the split view
// controller environment above it has changed. This notification will be
// posted when that happens (for example, when a split view controller is
// collapsing or expanding). The NSNotification's object will be the view
// controller that caused the change.
UIKIT_EXTERN NSNotificationName const UIViewControllerShowDetailTargetDidChangeNotification NS_AVAILABLE_IOS(8_0);
Use as follows
- (void)viewDidLoad{
[super viewDidLoad];
[[NSNotificationCenter defaultCenter] addObserver:self selector:#selector(showDetailTargetDidChange:) name:UIViewControllerShowDetailTargetDidChangeNotification object:self.splitViewController];
}
- (void)showDetailTargetDidChange:(NSNotification *)notification{
// changed from collapsed to expanded or vice versa
}
This Apple sample demonstrates how the table cell accessory changes from a disclosure indicator in portrait (denoting that a push will happen) to it being removed when changing to landscape split view:
https://developer.apple.com/library/archive/samplecode/AdaptivePhotos/Introduction/Intro.html
Note on iOS 13 beta, use addObserver with object nil because there currently is a bug they send the notification using the wrong object. They use their new UISplitViewControllerPanelImpl from the internal class cluster instead of the UISplitViewController object.
http://www.openradar.appspot.com/radar?id=4956722791710720
For future reference:
What about using the UISplitViewControllerDelegate??
It has a method called
splitViewController:willChangeToDisplayMode:
that should do exactly what you where looking for.
Documentation here
Okay, I found a simple solution. I was making a novice mistake. The trick is to override viewWillLayoutSubviews() instead of viewWillAppear(animated:). Then everything works as I want. It seems that viewWillLayoutSubviews() is called (sometimes more than once) every time the containing UISplitViewController changes its display mode, and that's exactly what I need to respond to. The only gotcha is that splitViewController might be nil on some of those calls, so it needs to be implemented like this:
override func viewWillAppear(_ animated: Bool) {
if let svc = splitViewController {
if svc.primaryHidden {
// hide the "close" UI artifact
} else {
// show the "close" UI artifact
}
}
}
As part of my stumbling around to find a solution, I tried overriding traitCollectionDidChange(previousTraitCollection:). (I tried this because I wanted to react to device rotations.) At first I thought I was onto something, because this function also get called whenever the device rotates. Interestingly (and, frustratingly) I found that my view's splitViewController property was nil when this function is called. It seems odd that this should be so, since neither viewDidDisappear(animated:) nor viewWillAppear(animated:) is called when the UISplitViewController reconfigures itself. But why it should be nil is, I suppose, a question for another day.

UITableView is resetting its background color before view appears

I'm using probably a little bit exotic way of initialization of my UI components. I create them programmatically and among them is a UITableView instance, I set its background color immediately upon initialization, like this:
class MyViewController: UIViewController {
...
let tableView = UITableView().tap {
$0.backgroundColor = .black
$0.separatorStyle = .none
}
...
}
where tap is extension function:
func tap(_ block: (Self) -> Void) -> Self {
block(self)
return self
}
This worked very well in my previous project which was created in Xcode 8 and then migrated to Xcode 9 without breaking anything. But now I've created brand new project in Xcode 9 and copy-pasted above-mentioned extension to it, but seems like something went wrong. When my view controller appears on screen table has white background and default separator insets.
This seems to affect only some of the properties because others are working as they should have (e.g. $0.register(nib: UINib?, forCellReuseIdentifier: String) registers required cell class and $0.showsVerticalScrollIndicator = false hides scroll indicator).
Perhaps some of you, guys, could give me an idea what's the heart of the matter.
Here's full code, to reproduce the issue simply create a new project and replace ViewController.swift's content. As you can see, table has correct rowHeight (160) but resets its background color.
As for "before view appears" statement: I've printed table's background color in viewDidLoad, viewWillAppear and viewDidAppear like this:
print(#function, table.backgroundColor.debugDescription)
– it changes its color only in the last debug print:
viewDidLoad() Optional(UIExtendedGrayColorSpace 0 1)
viewWillAppear Optional(UIExtendedGrayColorSpace 0 1)
viewDidAppear Optional(UIExtendedSRGBColorSpace 1 1 1 1)
I ended up moving the initialization to lazy var's function – turns out initializing UITableView during the initialization of it's view controller has some side effects.

Resources