onmyway133 / blog

🍁 What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
676 stars 33 forks source link

How to run UI Test with Facebook login #44

Open onmyway133 opened 7 years ago

onmyway133 commented 7 years ago

Today I'm trying to run some UITest on my app, which uses Facebook login. And here are some of my notes on it.

Challenges

Create a Facebook test user

Luckily, you don't have to create your own Facebook user to test. Facebook supports test users that you can manage permissions and friends, very handy

test user

When creating the test user, you have the option to select language. That will be the displayed language in Safari web view. I choose Norwegian 🇳🇴 for now

language

Click the login button and show Facebook login

Here we use the default FBSDKLoginButton

var showFacebookLoginFormButton: XCUIElement {
  return buttons["Continue with Facebook"]
}

And then tap it

app.showFacebookLoginFormButton.tap()

Check login status

When going to safari Facebook form, user may have already logged in or not. So we need to handle these 2 cases. When user has logged in, Facebook will say something like "you have already logged in" or the OK button.

The advice here is to put breakpoint and po app.staticTexts, po app.buttons to see which UI elements are at a certain point.

You can check for the static text, or simply just the OK button

var isAlreadyLoggedInSafari: Bool {
  return buttons["OK"].exists || staticTexts["Du har allerede godkjent Blue Sea."].exists
}

Wait and refresh

But Facebook form is a webview, so its content is a bit dynamic. And UITest seems to cache content for fast query, so before checking staticTexts, we need to wait and refresh the cache

app.clearCachedStaticTexts()

This is the wait function

extension XCTestCase {
  func wait(for duration: TimeInterval) {
    let waitExpectation = expectation(description: "Waiting")

    let when = DispatchTime.now() + duration
    DispatchQueue.main.asyncAfter(deadline: when) {
      waitExpectation.fulfill()
    }

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}

Wait for element to appear

But a more solid approach would be to wait for element to appear. For Facebook login form, they should display a Facebook label after loading. So we should wait for this element

extension XCTestCase {
  /// Wait for element to appear
  func wait(for element: XCUIElement, timeout duration: TimeInterval) {
    let predicate = NSPredicate(format: "exists == true")
    let _ = expectation(for: predicate, evaluatedWith: element, handler: nil)

    // Here we don't need to call `waitExpectation.fulfill()`

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}

And call this before you do any further inspection on elements in Facebook login form

 wait(for: app.staticTexts["Facebook"], timeout: 5)

If user is logged in

After login, my app shows the main controller with a map view inside. So a basic test would be to check the existence of that map

if app.isAlreadyLoggedInSafari {
  app.okButton.tap()

  handleLocationPermission()
  // Check for the map
  XCTAssertTrue(app.maps.element(boundBy: 0).exists)
}

Handle interruption

You know that when showing the map with location, Core Location will ask for permission. So we need to handle that interruption as well. You need to ensure to call it early before the alert happens

fileprivate func handleLocationPermission() {
  addUIInterruptionMonitor(withDescription: "Location permission", handler: { alert in
    alert.buttons.element(boundBy: 1).tap()
    return true
  })
}

There is another problem, this monitor won't be called. So the workaround is to call app.tap() again when the alert will happen. In my case, I call app.tap() when my map has been shown for 1,2 seconds, just to make sure app.tap() is called after alert is shown

For a more detailed guide, please read https://github.com/onmyway133/blog/issues/48

If user is not logged in

In this case, we need to fill in email and password. You can take a look at the The full source code section below. When things don't work or po does not show you the elements you needed, it's probably because of caching or you need to wait until dynamic content finishes rendering.

You need to wait for element to appear

Tap on the text field

You may get Neither element nor any descendant has keyboard focus, here are the workaround

app.emailTextField.tap()

Clear all the text

The idea is to move the caret to the end of the textField, then apply each delete key for each character, then type the next text

extension XCUIElement {
  func deleteAllText() {
    guard let string = value as? String else {
      return
    }

    let lowerRightCorner = coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.9))
    lowerRightCorner.tap()

    let deletes = string.characters.map({ _ in XCUIKeyboardKeyDelete }).joined(separator: "")
    typeText(deletes)
  }
}

Change language

For my case, I want to test in Norwegian, so we need to find the Norwegian option and tap on that. It is identified as static text by UI Test

var norwegianText: XCUIElement {
  return staticTexts["Norsk (bokmål)"]
}
wait(for: app.norwegianText, timeout: 1)
app.norwegianText.tap()

The email text field

Luckily, email text field is detected by UI Test as text field element, so we can query for that. This uses predicate

var emailTextField: XCUIElement {
  let predicate = NSPredicate(format: "placeholderValue == %@", "E-post eller mobil")
  return textFields.element(matching: predicate)
}

The password text field

UI Test can't seem to identify the password text field, so we need to search for it by coordinate

var passwordCoordinate: XCUICoordinate {
  let vector = CGVector(dx: 1, dy: 1.5)
  return emailTextField.coordinate(withNormalizedOffset: vector)
}

This is the document for func coordinate(withNormalizedOffset normalizedOffset: CGVector) -> XCUICoordinate

Creates and returns a new coordinate with a normalized offset. The coordinate's screen point is computed by adding normalizedOffset multiplied by the size of the element’s frame to the origin of the element’s frame.

Then type the password

app.passwordCoordinate.tap()
app.typeText("My password")

We should not use app.passwordCoordinate.referencedElement because it will point to email text field ❗️ 😢

Run that test again

Go to Xcode -> Product -> Perform Actions -> Test Again to run the previous test again

again

The full source code

import XCTest

class LoginTests: XCTestCase {
  var app: XCUIApplication!

  func testLogin() {
    continueAfterFailure = false
    app = XCUIApplication()
    app.launch()

    passLogin()
  }
}

extension LoginTests {
  func passLogin() {
    // Tap login
    app.showFacebookLoginFormButton.tap()
    wait(for: app.staticTexts["Facebook"], timeout: 5) // This requires a high timeout

    // There may be location permission popup when showing map
    handleLocationPermission()    

    if app.isAlreadyLoggedInSafari {
      app.okButton.tap()

      // Show map
      let map = app.maps.element(boundBy: 0)
      wait(for: map, timeout: 2)
      XCTAssertTrue(map.exists)

      // Need to interact with the app for interruption monitor to work
      app.tap()
    } else {
      // Choose norsk
     wait(for: app.norwegianText, timeout: 1)
      app.norwegianText.tap()

      app.emailTextField.tap()
      app.emailTextField.deleteAllText()
      app.emailTextField.typeText("mujyhwhbby_1496155833@tfbnw.net")

      app.passwordCoordinate.tap()
      app.typeText("Bob Alageaiecghfb Sharpeman")

      // login
      app.facebookLoginButton.tap()

      // press OK
      app.okButton.tap()

      // Show map
      let map = app.maps.element(boundBy: 0)
      wait(for: map, timeout: 2)
      XCTAssertTrue(map.exists)

      // Need to interact with the app for interruption monitor to work
      app.tap()
    }
  }

  fileprivate func handleLocationPermission() {
    addUIInterruptionMonitor(withDescription: "Location permission", handler: { alert in
      alert.buttons.element(boundBy: 1).tap()
      return true
    })
  }
}

fileprivate extension XCUIApplication {
  var showFacebookLoginFormButton: XCUIElement {
    return buttons["Continue with Facebook"]
  }

  var isAlreadyLoggedInSafari: Bool {
    return buttons["OK"].exists || staticTexts["Du har allerede godkjent Blue Sea."].exists
  }

  var okButton: XCUIElement {
    return buttons["OK"]
  }

  var norwegianText: XCUIElement {
    return staticTexts["Norsk (bokmål)"]
  }

  var emailTextField: XCUIElement {
    let predicate = NSPredicate(format: "placeholderValue == %@", "E-post eller mobil")
    return textFields.element(matching: predicate)
  }

  var passwordCoordinate: XCUICoordinate {
    let vector = CGVector(dx: 1, dy: 1.5)
    return emailTextField.coordinate(withNormalizedOffset: vector)
  }

  var facebookLoginButton: XCUIElement {
    return buttons["Logg inn"]
  }
}

extension XCTestCase {
  func wait(for duration: TimeInterval) {
    let waitExpectation = expectation(description: "Waiting")

    let when = DispatchTime.now() + duration
    DispatchQueue.main.asyncAfter(deadline: when) {
      waitExpectation.fulfill()
    }

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }

  /// Wait for element to appear
  func wait(for element: XCUIElement, timeout duration: TimeInterval) {
    let predicate = NSPredicate(format: "exists == true")
    let _ = expectation(for: predicate, evaluatedWith: element, handler: nil)

    // We use a buffer here to avoid flakiness with Timer on CI
    waitForExpectations(timeout: duration + 0.5)
  }
}

extension XCUIApplication {
  // Because of "Use cached accessibility hierarchy"
  func clearCachedStaticTexts() {
    let _ = staticTexts.count
  }

  func clearCachedTextFields() {
    let _ = textFields.count
  }

  func clearCachedTextViews() {
    let _ = textViews.count
  }
}

extension XCUIElement {
  func deleteAllText() {
    guard let string = value as? String else {
      return
    }

    let lowerRightCorner = coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.9))
    lowerRightCorner.tap()

    let deletes = string.characters.map({ _ in XCUIKeyboardKeyDelete }).joined(separator: "")
    typeText(deletes)
  }
}

Read more

I found these guides to cover many aspects of UITests, worth taking a look

jaunesarmiento commented 7 years ago

Hey @onmyway133, this was very useful even in testing Facebook Login on React Native apps! By the way, I found a way to find the password field instead of having to rely on its coordinates. The key is using XCUIApplication().secureTextFields instead of looking for generic textFields.

As of writing, the code below works:

let predicate = NSPredicate(format: "placeholderValue == %@", "Facebook Password")
let passwordTextField = app.secureTextFields.element(matching: predicate)

Hope this helps improve things on your end. Cheers! 🎉

onmyway133 commented 7 years ago

@jaunesarmiento Hi, thanks. Glad that you find it useful

Kirow commented 6 years ago

Was not able to interact with permission alert (not discussed in this guide, maybe it was added after), so finished up with XCUIApplication().launchArguments to skip login process: token from Graph API explorer and user id (GET me?fields=id)

let app = XCUIApplication()
 app.launchArguments += [
      "-FACEBOOK_TOKEN", facebookUserToken,
      "-FACEBOOK_USER", facebookUserId
]
app.launch()

and modified login method from the app:

    NSString *testUserToken = [[NSUserDefaults standardUserDefaults] stringForKey:@"FACEBOOK_TOKEN"];
    NSString *testUserId = [[NSUserDefaults standardUserDefaults] stringForKey:@"FACEBOOK_USER"];
    if (testUserToken && testUserId) {
        FBSDKAccessToken *token = [[FBSDKAccessToken alloc] initWithTokenString:testUserToken
                                          permissions:permissions
                                  declinedPermissions:@[]
                                                appID:appId //https://developers.facebook.com/apps/{id}
                                               userID:testUserId
                                       expirationDate:nil refreshDate:nil];

        [FBSDKAccessToken setCurrentAccessToken:token];
    }
BJap commented 6 years ago

Two things:

This won’t work if you are testing in other locales normally because the text will change. It’s an unfortunate thing but often it involves using an ‘xpath’ of sorts depending on how you are testing.

I would suggest making an extension for the XCUIElement class and make the wait function part of that instead. This would complement the other state functions in that class well.

onmyway133 commented 6 years ago

@BJap Hi, thanks for the suggestion, I will link to yours in the article

BJap commented 6 years ago

For sure. As a followup, sources below are included.

There are nice state functions in XCUIElement and a nifty new one added in Xcode 9

https://developer.apple.com/documentation/xctest/xcuielement/2879412-waitforexistence

The comprehensive list of element state query functions is here:

https://developer.apple.com/documentation/xctest/xcuielement

Good article 👍