아, 나도 테스트 코드 짠다! - (2) Xcode UI 테스트 작성

프론트엔드 또는 앱 개발을 할때는 사용자의 입력을 받게 되는데, 사용자의 입력이 제대로 처리 되는지를 테스트를 하기 위해 UI 테스트를 하게 된다. 이 테스트는 사람이 할 수도 있지만, 매번 업데이트 될 때마다 같은 기능을 사람이 직접 테스트 하는건 시간 낭비일 경우가 많다.

시간 낭비는 개발자의 도리가 아니기에, 이런 테스트를 기계가 할 수 있도록 도와주는 UI 테스트 자동화 툴이 많이 나와 있다. iOS와 Mac 앱의 경우 Xcode 에서 제공하는 UI 테스트 타겟을 사용하면 자동화를 쉽게 구현할 수 있게 해준다. Xcode 에서 제공하는 UI 테스트 툴은 실제 앱 타겟 내부의 코드에는 접근할 수 없지만, UI를 코드로 조작할 수 있다.

준비하기

UI 테스트를 하기 위해서는 새로운 타겟 추가가 필요한데, 타겟 추가는 기존 테스트 타겟 추가와 유사하게 이루어 진다. Test Navigator 좌측 하단에 있는 추가 버튼을 누르고 나서 New UI Test Target... 을 선택해주면 테스트 타겟 추가와 동일한 화면이 뜨는데 적당히 설정해주고 넘어가면 된다.

새로 만든 타겟의 소스 파일 내에서 Debug Area 를 열어보면 평소에 못 보던 빨간 녹화 버튼이 있는걸 확인 할 수 있다. 이 버튼을 누르면 앱이 실행되는데, 실행되는 동안 조작된 UI 동작들이 코드의 형태로 기록이 되는것을 확인할 수 있다.

원하는 만큼 기록이 끝나고 녹화 버튼을 다시 누르면 기록이 완료된다. 기록한 내용들 중 UI 요소들은 변수로 추출이 가능하며 UI 요소를 찾을때 검색 범위를 줄이는데에 사용할 수 있다.

UI 요소 검색, 핸들링

코드 상에서 UI 요소를 검색할 수도 있는데, 최상위 객체인 XCUIApplication 객체로 부터 검색을 하게 된다. 간단하게 예를 들어 앱 위에 “번호 입력” 이라는 타이틀을 가지고 있는 UIAlertController 의 뷰를 찾고 싶다면 아래와 같이 검색할 수 있다.

let app = XCUIApplication()
let alert = app.alerts["번호 입력"]

버튼이나 텍스트 입력 폼도 같은 방법으로 찾을 수 있는데, 만약 이름을 알기 힘든 UI 요소를 찾으려고 할 경우에는 Xcode Developer Tools 중 Accessibility Inspector를 사용하여 해당 UI 요소의 라벨 명을 찾아 낼 수 있다.

좀 더 깔끔하게 검색을 하고 싶다면 스토리보드 또는 코드 상에서 Accessibility Identifier 값을 지정해 주는 것이 좋다. Identifier 값을 지정하면 해당 Identifier 를 사용하여 바로 검색이 가능해진다.

만약에 코드로 Identifier를 선언할때는 아래와 같이 사용하면 된다.

let label = UILabel()
label.accessibilityIdentifier = "LABEL_ACCESS_IDENTIFIER"

UI 요소를 찾았다면 원하는 대로 핸들링을 해야 한다. 동작을 기록하는 도중에도 확인 할 수 있었겠지만, 탭이나 스와이프 등의 동작들을 코드 상으로 핸들링 할 수 있다.

핸들링이 끝나고 결과를 확인할 때는 목표로 하는 UI 요소가 존재하는지를 검색하여 XCTAssertNotNil 로 판별 처리를 할 수 있다. 만약 비동기 요소가 있어서 대기를 해야 할 경우가 생긴다면 유닛 테스트에서 했던 것과 유사하게 waitForExpectation(timeout:handler:) 또는 wait(for:timeout:)를 사용하면 원하는 시간만큼 테스트 흐름을 대기 시킬 수 있다.

테스트 후 처리

주: 이 부분에서 언급하는 부분은 기존 Unit Test 에서도 사용이 가능한 부분이지만 UI Test 글을 준비하면서 찾았던 기능이라 여기서 언급하도록 하겠습니다.

하나의 테스트가 끝나고 나면 원래 상태로 돌려놔야 다음 테스트를 원활하게 할 수 있는건 당연한 말이다. 그렇기 때문에 XCTestCasetearDown 메소드를 오버라이딩 하여 테스트 리셋을 시키는데, 테스트마다 복구하는 방법이 다를 수 있기 때문에 테스트 메소드마다 addTeardownBlock(_:) 함수를 사용하여 테스트 종료시 복구하는 방법을 다르게 선언해 주는 것이 가능하다.

addTeardownBlock {
	app.tables.cells.staticTexts["SOME"].firstMatch.swipeLeft()
	app.buttons["Delete"].firstMatch.tap()
}
  • 앱 상의 테이블 뷰 첫번째 셀에 있는 “SOME” 이라는 텍스트를 왼쪽으로 스왑한다. 내 앱의 경우 우측에 Delete 버튼이 노출되게 된다.
  • 우측에 노출된 Delete 버튼을 눌러 테스트시 추가된 아이템을 삭제한다.

위에서 언급한 것들을 사용하여 만든 테스트 코드는 다음과 같다. 이 테스트 코드는 특정 아이템 번호를 넣었을 때 비동기 작업을 처리하여 정상적으로 테이블에 추가되는지를 확인하는 코드이다.

func testAddItemWithWriteButton() {
    let app = XCUIApplication()
    let targetText = "Item No.\(TestItemNumber)"
    
    // UI 자동 조작
    app.navigationBars["아이템 목록"].buttons["icon edit"].tap()
    
    let alert = app.alerts["아이템 번호 입력"]
    alert.collectionViews.textFields["ex) 1234567"].typeText("\(TestItemNumber)")
    alert.buttons["확인"].tap()
    
    // 비동기 작업 대기 후 체크
    let predicate = NSPredicate(format: "exists == true")
    let promise = expectation(for: predicate,
                              evaluatedWith: app.tables.cells.staticTexts[targetText],
                              handler: nil)
    wait(for: [promise], timeout: TimeoutSeconds)
    
    addTeardownBlock {
        app.tables.cells.staticTexts[targetText].firstMatch.swipeLeft()
        app.buttons["Delete"].firstMatch.tap()
    }
}
  • 네비게이션 바에 있는 특정 버튼을 탭하면 Alert이 노출되고 해당 Alert 내부의 Text Field에 Text를 입력 후 확인 버튼을 누르게 한다.
  • 비동기 작업을 처리하기 위해 추가 대기 시간을 주고 Expectation이 완료되는 것을 기다린다.
  • 테스트가 끝나면 addTeardownBlock 내에서 선언 한 초기화 작업을 수행하고 테스트를 종료한다.

결론

유닛 테스트의 경우 “무엇을 테스트해야 하는가” 라는 질문때문에 어려워 했던 것에 비해서 UI 테스트는 테스트 할 것이 바로 눈에 보이기 때문에 테스트를 작성하기가 좀 더 수월했다. 그리고 기존에 작성된 코드라도 UI 테스트를 하는데에는 큰 지장이 없어 보이니 먼저 도입하는 것도 나쁘지 않을 것 같다.

다음은 만들어진 테스트를 외부 툴과 연동하는 작업에 대해 알아볼 것이다. 그리고 부족했던 내용이 있다면 다음 글에서 추가로 언급해 보도록 하겠다.

참고 자료