개발/ios

[iOS] UI Test 를 자동화 해보자. - Basic 사용법

나인에스 2022. 2. 20. 21:00

개요

UI Test 는 시뮬레이터 상에서 실제 앱을 구동시켜서 가상 컨트롤러를 이용해서 정해진 시나리오 대로 조작 후 해당 시나리오에 대한 결과를expected result와 비교하여 테스트의 성공/실패를 결정한다.

UI Test는 개발자들사이에서는 많이 사용되지 않는 기능이기는 하나 개발 리소스 혹은 QC에 따로 리소스를 투입하기 힘든 스타트업과 같은 회사에서는 릴리즈시 테스트에 들어가는 비용을 줄여줄 수 있다.

다만 UI Test 또한 적지 않은 단점이 존재 하는데 이중 가장 큰것은 UI/UX가 바뀔때마다 테스트 또한 다시 작성하거나 수정을 해야하는 단점이 있다. UI Test는 화면상에 보여지는 글자, 문자열, 버튼, 아이콘 등 현재의 element를 기준으로 작성이 되고 tap, input text, 화면 전환등 시나리오에 맞는 action을 취했을때 해당 element가 결과에 맞게 표시 혹은 전환, 변경되어 있는지를 보고 성공/실패를 판단하기 때문에 UI/UX가 변경이 된다면 그에 맞게 test또한 수정이 되어야 한다.

위와 같은 단점에도 불구하고 UI/UX가 자주 바뀌지 않거나 큰 틀에서 벗어나지 않는 프로젝트의 경우 UI Test는 리소스 비용 적약과 앱의 안정성 및 높은 퀄리티를 유지할 수 있는 매우 유용한 기능이 될 수 있다.


 

1.  podfile 설정

UI Test는 위에서 언급한 것과 같이 시뮬레이터 상에서 앱을 실행 한 다음 앱의 UI들에 action 을 직접적으로 입력해서 동작시키기 때문에 빌드시 앱의 릴리즈 빌드와 동일한 수준의 pod가 필요하다.

아래와 같이 앱에서 사용되는 pod들을 def XXX 문구를 이용해서 define한 다음 UI Test target에도 포함되어 빌드될 수 있도록 한다.

# 사용되는 pod들을 define 해서 일반 빌드와 UI Test빌드 양쪽에서 사용할 수 있도록 한다.
def sharedPod 
  # Rx
    pod 'RxSwift', '~> 4.5.0'
    pod 'RxGoogleMaps', :path => './'
    
    
  # Firebase

    pod 'Firebase/Core', '~> 7.10.0'
    pod 'Firebase/RemoteConfig', '~> 7.10.0'
    pod 'Firebase/Performance', '~> 7.10.0'
    pod 'Firebase/Messaging', '~> 7.10.0'
    pod 'Firebase/Analytics', '~> 7.10.0'
    pod 'Firebase/Crashlytics', '~> 7.10.0'
    
  # AWS

    pod 'AWSCore', '~> 2.23.3'
    pod 'AWSCognitoIdentityProvider', '~> 2.23.3'
    pod 'Amplify', '~> 1.8.0'
    pod 'AmplifyPlugins/AWSCognitoAuthPlugin', '~> 1.8.0'


  # UI

    pod 'SnapKit', '~> 4.2.0'
    pod 'MarqueeLabel', '~> 4.0.5'
    pod 'EasyTipView', '~> 2.1.0'
    pod 'FSPagerView', '~> 0.8.3'
    pod 'Kingfisher', '~> 7.0'
    
  # Amplitude
    pod 'Amplitude', '~> 8.5.0'

  # Misc
    pod 'Fabric', '~> 1.10.2'
    pod 'Branch',  '0.27.1'
    pod 'ReachabilitySwift',   '4.3.1'
    pod 'NMapsMap'
    pod 'CryptoSwift', '~> 1.3.8'
    pod 'ChannelIOSDK', podspec: 'https://mobile-static.channel.io/ios/9.1.2/xcframework.podspec' # 채널톡
    
end
target 'UITESTExample' do
	# 위에서 define 한 항목을 정의
    sharedPod
    
    target 'UITESTExampleTests' do
      inherit! :search_paths
      # Pods for testing
    end
    
    target 'UITESTExampleUITests' do
      inherit! :search_paths
      # Pods for testing
	# UI Test에서도 일반 빌드와 동일한 pod를 사용하기 위에서 define 한 항목을 정의
      sharedPod
    end
end

2. UI Test를 위한 syntax 사용법

swift의 UI component는 모두 accessibilityIdentifier 이라는 member variable을 가지고 있다. 이는 UI Test에서 접근하려고 하는  element 혹은 component 를 찾을때 유용하게 사용되기 때문에 모든 ui component에 고유한 string을 넣어서 관리하는것이 UI Test를 쉽게 작성하는 방법중의 하나이다.

아래와 같이 UIImageView에 accessibilityIdentifier를 설정 후 UI Test에서 간편하게 해당 imageView에 접근할 수 있다.

// source code
var guideImage: UIImageView = UIImageView()
guideImage.accessibilityIdentifier = "guide_image" // image view에 접근할 문자열 설정

// UI Test Code
let app = XCUIApplication()
app.images["guide_image"].exists // guide_image 라는 image view에 접근 및 존재 하는지 확인

XCTAssert

시나리오대로 action을 취했을때 화면상에 표시되는 아이콘, 버튼, 이미지, 문구 등이 정상적으로 표시가 되었는지 확인 하고 Test Case의 성공 / 실패를 판단 할때에 사용한다. XCTAssert외에도 XCTFail, XCTAssertTrue 등 여러 method가 많이 있기 때문에 상황에 맞는 method를 사용해서 성공/실패를 판단하면 된다.

// check button이 있는 경우 성공
XCTAssert(app.buttons["check"].exists)

// "TEST" 라는 문자열이 있는 경우 성공
XCTAssert(app.staticTexts["TEST"].exists)

// 2초 내로 "TEST2" 라는 문자열이 보여지는 경우 성공
XCTAssert(app.staticTexts["TEST2"].waitForExistence(timeout: 2))

// 무조건 실패
XCTFail()

// image1 이라는 이미지가 있는 경우 성공, 실패하는 경우 message 출력
XCTAssert(app.images["image1"].exists, "There is no image1 image")

 

Button Tap  / Double Tap

화면상의 "button1" 버튼을 tap 한다.

let app = XCUIApplication()
app.buttons["button1"].tap()
app.buttons["button2"].doubleTap()

NavigationBars 

NavigationBars중 "my page" 라는 항목에 접근한다.

let app = XCUIApplication()
app.navigationsBars["my page"]

image 확인

화면 상에 image_name이라는 이미지가 있는지 확인할 수 있다.

app.images["image_name"].exists

화면 상의 특정 위치 tap

아래 예제는 탭 위치는 {X 좌표 : width를 8등분해서 오른쪽에서 1/8 만큼 떨어지 위치, Y 좌표 : 화면의 중간} 위치를 가상 컨트롤러가 tap하도록 한다. 

let pointX = (app.frame.width/8) * 7
let pointY = app.frame.height/2
let pointLoc = CGVector(dx: pointX, dy: pointY)
let normalizedLoc = app.coordinate(withNormalizedOffset: CGVector(dx:0, dy: 0))
let tapLoc = normalizedLoc.withOffset(pointLoc)
tapLoc.tap()

화면상의 이미지 확인

UITest에서 특정 image를 확인하기 위해서는 UIImageView의 accessibilityIdentifier에 assign되어 있는 name(일반적으로 resource name을 사용)을 이용해서 찾을 수 있다.

우선 아래와 같이 UIImageView의 accessibilityIdentifier에 찾을 이미지의 특정 name을 넣고

// 단일 이미지의 경우
let imageView:UIImageView = UIImage(named: "resource_name")
imageView.accessibilityIdentifier = "resource_name"

// 여러 image를 상황에 따라 하나의 imageView에 보여주는 경우
var imageName:String = "default_resource_name"
switch index {
case 0:
	imageName = "resource_name_1"
case 1:
	imageName = "resource_name_2"
case 2:
	imageName = "resource_name_3"
case 3:
	imageName = "resource_name_4"
default:
    print("use default")
}
        
imageView.image = UIImage(named: imageName)
imageView.accessibilityIdentifier = imageName

아래와 같이 UI Test에서 해당 image가 현재 존재 하는지 확인할 수 있다.

let app = XCUIApplication()
XCTAssert(app.images["image_name"].exists, "There is no image_name")

TextField 의 입력값 가져오기

TextField에 입력된 값을 비교 해야 하는 경우 아래와 같은 방법으로 해당 입력 값을 가져온 다음 결과 값을 비교해서 유효성을 테스트 할 수 있다.

let value = app.textFields["TextFieldName"].value as! String
XCTassertEqual(value, "Expected String")

예제

위에서 설명한 기본 기능들을 이용해서 아래와 같은 테스트 코드를 작성할 수 있다.

아래 예제는

  1. "rideStart" 버튼을 클릭
  2. 사용자 이름을 입력하는 화면이 전환되면 해당 화면의 버튼, textField, image들을 확인해서 화면전환 성공 여부 확인 확인
  3. username을 자동으로 입력하고 인증 번호 요청 버튼을 클릭
  4. 인증 번호 입력 화면의 text, textField, button을 확인해서 화면 전환의 성공 여부 확인
  5. 인증 번호 자동입력
  6. 확인 버튼 클릭
  7. 로그인이 완료되어 메인 버튼의 text가 변경된 것을 확인 후 성공 여부를 확인

의 순서로 진행되며 탭, 입력등을 자동화 하고 화면이 전환 될때마다 해당 화면이 정상적으로 전환이되었는지 element들을 확인하고 있다.

import XCTest

class UITestLogin: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method.
        XCUIApplication().launch()

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testExample() throws {
        // Use recording to get started writing UI tests.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }
    
    func testlogin() throws {
        let app = XCUIApplication()
        guard app.buttons["rideStart"].exists else {
            XCTFail()
            return
        }
        
        if !app.buttons.staticTexts["로그인하기"].waitForExistence(timeout: 1) {
            // do logout
        }
        
        app.buttons["rideStart"].tap()
        XCTAssert(app.staticTexts["휴대폰 번호를 입력해 주세요."].exists)
        XCTAssert(app.textFields["usernameTextField"].exists)
        XCTAssert(app.buttons["signinViewAgreeCheck"].exists)
        XCTAssert(app.buttons["signinViewSigninButton"].exists)
        XCTAssert(app.buttons["icCheckBoxDefault"].exists)
        
        app.textFields["usernameTextField"].tap()
        app.textFields["usernameTextField"].replaceText(newText: "010-1111-2222")
        
        app.buttons["signinViewAgreeCheck"].tap()
        XCTAssert(app.buttons["icCheckBox"].waitForExistence(timeout: 2))
        
        app.buttons["signinViewSigninButton"].tap()
        XCTAssert(app.staticTexts["인증번호를 입력해 주세요."].waitForExistence(timeout: 2))
        XCTAssert(app.textFields["confirmCodeTextField"].exists)
        XCTAssert(app.buttons["signinViewConfirmButton"].exists)
        
        sleep(3) // wait for sending confirm code
        app.textFields["confirmCodeTextField"].typeText("777777")
        sleep(1) // wait for activating confirm button
        app.buttons["signinViewConfirmButton"].tap()
        XCTAssert(app.buttons.staticTexts["이용하기"].waitForExistence(timeout: 5))
    }
    
    func testLogout() throws {
        
    }

}

extension XCUIElement {
    func clearText(andReplaceWith newText:String? = nil) {
        //When there is some text, its parts can be selected on the first tap, the second tap clears the selection
        tap()
        tap()
        press(forDuration: 1.0)
        let selectAll = XCUIApplication().menuItems["Select All"]
        //For empty fields there will be no "Select All", so we need to check
        if selectAll.waitForExistence(timeout: 0.5), selectAll.exists {
            selectAll.tap()
            typeText(String(XCUIKeyboardKey.delete.rawValue))
        }
        if let newVal = newText { typeText(newVal) }
    }
    
    func replaceText(newText: String) {
        if let existedText = value as? String, !existedText.isEmpty {
            doubleTap()
        }
        typeText(newText)
    }
}