내가 iOS 앱 테스트를 이용하는 방법

백상휘·2022년 9월 4일
1

iOS_Programming

목록 보기
4/10
ENTJ 였는데?

테스트는 좋은 서비스를 위한 노력이고, 개발자로 몇년 더 밥 벌어먹고 살 수 있게 만들어 주는 생존전략이다.


처음 개발을 시작할 땐 "테스트" 라는 개념이 없었다.

iOS 개발을 처음 시작할 때엔 "테스트" 시도를 해보려고 하고 있던 찰나였다. 부트캠프에서는 테스트 작성이 강력 권고되고 있었다.

애플리케이션에서 많이 사용되는 REST-API, JSON 디코딩, Concurrency 테스트 등은 Unit-Test 를 이용하면 시뮬레이터 실행 없이 바로 적용 가능한 모델 객체를 만들 수 있다.

이 포스팅에서는 포트폴리오 용으로 진행되고 있는 프로젝트에서 본격적으로 테스트 환경을 구축해 본 나만의 방식을 공유하고자 한다. 코멘트는 언제든 환영이다.


이 포스팅에서 다루는 내용은 다음과 같습니다.

  • Run 없이 Unit-Test 로 Model 개발하기.
    - UseCase 별로 Test Class 생성하기.
  • Run 없이 UI-Test 로 View 개발하기.
    - UseCase 별로 Test Class 생성하기.
  • Test Coverage 를 이용해서 테스트 효율 확인하기.
테스트만 끼고 맨몸으로 맞서 싸워!!

테스트를 할 때 미리 알아두어야 할 사항

  1. 애플은 Test Case 를 여러 Test Method 의 집합으로 본다.
  2. 모든 Test Method 이름에는 "test" 라는 글자가 앞에 붙어야 한다.
  3. 모든 Test 코드에는 "이렇게 실행해서 이상 없다면 문제가 없다" 라는 확신이 있어야 한다.
  4. Test 는 선택사항. 현재 개발 프로세스와 맞지 않다면 진행하지 않아도 된다.

어떻게 추가할까?

가장 간단한 방법은 프로젝트를 만들 때 추가하는 것이다.

하지만, 이를 추가하지 못하였더라도 간단히 테스트를 추가할 수 있다. Xcode 의 왼쪽 Project Navigator 에서 프로젝트 파일을 클릭한다.

마지막 설정창에서 Project 는 소프트웨어 제품을 위한 모든 파일/리소스/정보를 포함하는 단위를 뜻하고, Target 은 빌드하는 설정과 절차의 최소 단위를 뜻한다.

한 테스트에 많은 Target 을 테스트할 필요는 없으므로, 테스트 하고자 하는 Target 이 속한 Project 를 알맞게 설정해주도록 하자.

기본적인 테스트 클래스의 구조

import XCTest // Xcode Test 프레임워크.

class NetworkTestCase: XCTestCase { // Test Case 들을 모아놓는 Test Case 서브클래스. 여기까지만 해도 Test Navigator에서 인식.
	// 시뮬레이터에서 실행되는 앱의 프록시 인스턴스. 앱이라고 보면 됨. 
    // Unit-Test 에서는 사용하지 않아도 됨.
	var app: XCUIApplication = XCUIApplication() 
    // Test Method 시작 마다 실행되는 준비 메소드.
	override func setUp() {
    	app.launch()
    }
    // Test Method 종료 마다 실행되는 준비 메소드.
    override func tearDown() {
    	app.terminate()
    }
    
    // Executable한 test method. 메소드 앞에 꼭 'test' 를 붙여주어야 한다.
    // XCTestCase 를 서브클래싱 했다 하더라도 이 test method가 선언되어 있어야 테스트 실행이 가능하다.
    func testNetworkModel() throws {
    	
    }
}

프로젝트에 관한 간략한 설명

프로젝트 이름은 Issue-Tracker 이다. Github의 Issue, Label, Milestone 기능에서 아이디어를 얻어서 사용자들끼리 이슈를 관리할 수 있도록 만드는 앱이다.

현재 개발해야 하는 목록은 다음과 같다.

  • Issue/Label/Milestone 리스트 가져오기.
  • UISegmentedControl 을 이용한 리스트 전환.

그리고 이를 위해 프로그래밍해야 하는 요소들은 다음과 같다.

  1. Model (Unit-Test 이용)
    • Issue/Label/Milestone List 를 불러오는 역할을 하는 객체들
  2. View (UI-Test 이용)
    • CommonTextField 클래스와 확장 클래스를 이용한 뷰 객체들
    • ContainerView 커스텀 클래스를 이용한 뷰 객체들

객체란 간단히 "애플리케이션의 목적 하나를 수행하는 클래스 혹은 클래스의 집합" 으로 정의한다.

Run 없이 Unit-Test 만으로 Model 개발하기

Model 을 작성하기 위해 다음의 계획을 세운다.

  1. Issue/Label/Milestone 리스트를 불러올 수 있다.
  2. 모든 파라미터는 임의로 정의하고, List 의 경우는 별도의 JSON 파일을 프로젝트에 추가합니다.

객체를 만들기 위해 다음의 객체들을 만들었다.

  • URLSessionTask 를 수행할 수 있는 RequestHTTPModel 클래스
  • URLRequest 를 생성할 수 있는 RequestBuilder 구조체
  • URLSessionTask 결과를 해석할 수 있는 HTTPResponseModel 클래스
  • JSON Decode/Encode 가 가능한 Entity 객체들 (IssueListEntity, LabelListEntity, MilestoneListEntity 등)

이 포스트는 "테스트를 어떻게 작성하는지" 에 집중하기 때문에 각 객체의 구현을 상세히 설명하진 않는다.

목적에 따라 여러 테스트 클래스를 만들어보기

Unit-Test 에서 테스트 해야 할 객체는 아래와 같다.

  1. URL Loading System 에 등록할 URLProtocol 서브 클래스.
  2. 목적이 명확하고 인터페이스가 간단한 Model 클래스.
/// IssueListProtocol.swift
enum ProtocolError: Error {
    case jsonSerializingError, urlConfirmationError
}

class IssueListProtocol: URLProtocol {
    
    static var url: URL? { /**중략. 로컬에 저장된 json 파일 가져옴.**/ }
    var protocolClient: URLProtocolClient?
    
    override class func canInit(with task: URLSessionTask) -> Bool { return true } // Required.
    override class func canInit(with request: URLRequest) -> Bool { return true } // Required.
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request } // Required.
    
    convenience init(task: URLSessionTask, cachedResponse: CachedURLResponse?, client: URLProtocolClient?) {
        self.init()
        self.protocolClient = client
    }
    
    private var response: Result<Data, ProtocolError> { // test 할 data를 Result 형태로 반환. 클래스 안에서만 사용.
        guard let url = Self.url, let data = FileManager.default.contents(atPath: url.absoluteString) else {
            return .failure(.jsonSerializingError)
        }
        
        return .success(data)
    }
    
    override func startLoading() {
        DispatchQueue.global(qos: .default).async {
            switch self.response { // custom response
            case .success(let data):
                self.protocolClient?.urlProtocol(self, didLoad: data)
            case .failure(let error):
                self.protocolClient?.urlProtocol(self, didFailWithError: error)
            }
            
            self.protocolClient?.urlProtocolDidFinishLoading(self)
        }
    }
    override func stopLoading() {}
}

Issue List 를 Network 를 통해 불러오는 것을 시뮬레이션할 수 있는 Protocol 객체

/// IssueListRequestModel.swift
final class IssueListRequestModel: RequestHTTPModel {    
    typealias ReactiveResult = Data
    // Issue List 들을 모델 클래스에 저장함으로써, 다른 ViewController 에서도 참고할 수 있도록 하였음.
    // ViewController 는 View 를 관리하고 명령을 전달할 뿐, Entity 들을 저장하는 것은 Model의 역할.
    private(set) var issueList: [IssueListEntity] = []
    
    func requestIssueList(_ pageNumber: UInt = 0, requestHandler: ((Int, [IssueListEntity]?) -> Void)? = nil) {
    	/**중략. pageNumber 를 가져올 수 있는지 테스트 해야 함.**/
    }
    func reloadIssueList(reloadHandler: (([IssueListEntity]?)->Void)? = nil) {
        /**중략. 현재 저장된 issueList를 초기화 하는지 테스트 해야 함.**/
    }
}

구체적으로 Isssue List 를 불러오는 모델 객체

/// issue_trackerUnitTest_Issue.swift
class issue_trackerUnitTest_Issue: XCTestCase {
    
    var issueModel: IssueListRequestModel!
    let issueExpectation = XCTestExpectation()

    override func setUp() { // 각 test 메소드가 실행될 때마다 실행되는 셋업 메소드.
        URLProtocol.registerClass(IssueListProtocol.self)
        
        guard let issueURL = IssueListProtocol.url else {
            XCTFail("[Error] \(#file) get URL failed.")
            return
        }
        
        issueModel = IssueListRequestModel(issueURL)
    }
    override func tearDown() {
        URLProtocol.unregisterClass(IssueListProtocol.self)
    }

    func testIssueList() throws {
        issueExpectation.expectedFulfillmentCount = 3 // fulfill() 이 3번 호출되어야 한다.
        
        // 데이터 무결성은 JSON 파일 디코딩이 가능한 것으로 검사 가능한 것으로 가정.
        // Response 만 성공적으로 오면 성공으로 간주.
        issueModel.requestIssueList(0) { _, _ in self.issueExpectation.fulfill() }
        issueModel.requestIssueList(1) { _, _ in self.issueExpectation.fulfill() }
        issueModel.reloadIssueList({ _ in self.issueExpectation.fulfill() })
        
        // 각 세부 Test-Case 마다 1.5초 할당.
        wait(for: [issueExpectation], timeout: (Double(issueExpectation.expectedFulfillmentCount) * 1.5))
    }
}

test 메소드(testIssueList)를 포함하는 클래스. 0번 페이지, 1번 페이지, 모든 리스트를 RELOAD 해서 응답이 온다면 Issue List 불러오기 모델 기능 테스트는 성공이다.

  • XCTestExpectation = 비동기 테스트를 진행할 때 사용. wait() 메소드를 이용해서 지정된 timeout 값 만큼 기다려보고 fulfill() 메소드가 호출되지 않으면 테스트 실패. expectedFulfillmentCount 를 이용해서 fulfill() 이 호출되어야 할 횟수도 지정할 수 있다.

이 테스트는 성공했을까? 아니나 다를까 실패했다.... 오히려 좋다는 생각으로 원인을 찾아보았다.

자세한 디버깅 과정은 생략했다.

/// issue_trackerUnitTest_Issue.swift -> testIssueList()
func testIssueList() throws {
    issueExpectation.expectedFulfillmentCount = 3
    
    issueModel.requestIssueList(0) { _, _ in self.issueExpectation.fulfill() } // 호출 안됨
    issueModel.requestIssueList(1) { _, _ in self.issueExpectation.fulfill() } // 호출 안됨
    issueModel.reloadIssueList({ _ in self.issueExpectation.fulfill() }) // 호출 됨
        
    wait(for: [issueExpectation], timeout: (Double(issueExpectation.expectedFulfillmentCount) * 1.5))
}

주석에 나와있듯이 IssueListRequestModel 클래스의 requestIssueList(_: _:) 에 문제 가 있다는 것을 확인할 수 있었다.

/// IssueListRequestModel.swift
final class IssueListRequestModel: RequestHTTPModel {
    typealias ReactiveResult = Data
	
    private(set) var issueList: [IssueListEntity] = []
    
    func requestIssueList(_ pageNumber: UInt = 0, requestHandler: ((Int, [IssueListEntity]?) -> Void)? = nil) {
    	/**호출이 안됨.**/
        let pageNumberInteger = Int(pageNumber)
        let pathArray = ["\(pageNumberInteger)"]
        requestObservable(pathArray: pathArray)
            .subscribe(
                onNext: { data in
                    guard let list = HTTPResponseModel().getDecoded(from: data, as: [IssueListEntity].self) else { // HTTPResponseModel 은 result 들을 실제 Entity로 변환해주는 커스텀 클래스.
                        return
                    }
                    
                    self.issueList.append(contentsOf: list)
                    if let requestHandler = requestHandler { // 전달된 클로저가 더 높은 우선순위를 가짐.
                        requestHandler(Int(pageNumber), list)
                    } else {
                        self.nextHandler?(Int(pageNumber), list)
                    }
                },
                onError: errorHandler
            )
            .disposed(by: disposeBag)
    }
    
    func reloadIssueList(reloadHandler: (([IssueListEntity]?)->Void)? = nil) {
        /**중략. 성공.**/
    }
}

여기서도 디버깅을 해 봤더니 onNext 아래로는 실행이 되지 않았다. requestHandler 로 클로저를 전달하였지만, 실행 되지 않은 것이다. 여기서 중요한 사실은 pathArray 를 정의해서 넘겨주었다는 것인데, pathArray 는 URL의 ContextPath 이다.

requestObservable(_:) 로 들어가보자. 이 메소드는 IssueListRequestModel 의 super class 인 RequestHTTPModel 에 정의된 메소드이다. Network Request를 하는 RxSwift의 Observable을 생성한다.

/// RequestHTTPModel.swift, 간략히 설명하기 위해 많은 부분을 제외.
class RequestHTTPModel {

	func requestObservable(pathArray: [String] = []) -> Observable<Data> {
    	Observable.create { observable in
			self.pathArray = pathArray
        	guard let request = self.getRequest() else {
            	observable.onError(HTTPError.urlError)
	            return Disposables.create()
    	    }
        	
	        URLSession.shared.dataTask(with: request) { data, response, error in
    	        guard let data = data else {
        	        observable.onError(error ?? HTTPError.noData)
            	    return
            	}
            	
	            observable.onNext(data)
    	        observable.onCompleted()
        	}.resume()
        	
	        return Disposables.create()
    	}
	}
}

여기까지 도달하자 문제점을 확인할 수 있었다. pathArray 로 페이지 번호를 전달해서 페이지 번호 자체가 ContextPath로 들어가 버린 것이었다.

IssueListProtocol.swift response에 넣을 데이터를 얻기 위해 파일의 URL을 가져오는 코드를 변경해야 한다.

/// 가독성을 위해 많은 부분의 중복을 제거.
class IssueListProtocol: URLProtocol {
	var response: Result<Data, ProtocolError> {
        // 오류 코드는 주석처리.
    	// guard let url = Self.url, let data = FileManager.default.contents(atPath: url.absoluteString) else {
        //     return .failure(.jsonSerializingError)
        // }
        //
        // return .success(data)
    
        guard var url = Self.url else {
            return .failure(.urlConfirmationError("filePathURL is nil"))
        }

        while url.lastPathComponent.lowercased().contains("json") == false {
            url = url.deletingLastPathComponent()
        }

        for urlString in [url.absoluteString, url.relativePath, url.relativeString] {
            if let data = FileManager.default.contents(atPath: urlString) {
                return .success(data)
            }
        }

        return .failure(.jsonSerializingError("no file at '\(url.relativePath)'"))
	}
}

결과는 아래와 같다. 우린 이를 통해 IssueListRequeestModel 에 적절한 URL만 넣어 준다면 원하는 Entity 를 반환하는 모델을 생성한 것이다.

정말 다 테스트한 것일까? Test-Coverage

놀랍게도 테스트도 검증이 필요하다. 개인적으로 테스트를 통해 입증하고 싶은 요소는 다음과 같다. 이들을 입증하기 위해 Test-Coverage 를 사용했다.

  • 이 코드는 효과적으로 개발 효율성을 높이고, 버그를 줄여줄 수 있다.
  • 내가 작성한 테스트 코드와 모델은 믿을만하다.

Test-Coverage 는 테스트 타겟이 된 객체 내 소스코드가 테스트코드에 의해 실행된 영역을 계산하는 것을 말한다. 정말 작성한 코드들을 모두 테스트하였을까?


[Product]->[Scheme]->[Edit Scheme]->왼쪽 'Test' 메뉴->Test 메뉴의 'Options' 탭

Test-Coverage 기능을 사용하려면 설정을 좀 만질 필요가 있다. 하이라이트 된 부분의 '+' 버튼을 이용하면 프로젝트의 Target을 넣을 수 있다.

이젠 소스코드 오른쪽의 작은 영역을 통해 테스트가 훑고 지나간 코드의 Coverage 를 확인할 수 있다. 만약 확인이 불가능 하다면 [Editor]->[Code Coverage] 가 체크되어 있는지 확인한다.

참고로, Text Coverage에 의해 측정이 되려면 아래의 그림처럼 'Target Membership' 에 해당하는 Test Target 을 체크해두어야 한다.


위에서 진행한 테스트 결과를 Xcode 의 Report Navigator(단축키 Command+9) 에서 확인해보도록 하자. 빨간색 부분을 선택한 뒤, 확인을 원하는 타겟이나 파일을 찾아가면 얼마나 테스트를 통해 검증되었는지 확인한다. 앞에서 설명했듯, 노란 사각형 부분의 swift 파일들은 'Target Membership' 에서 테스트한 타겟을 체크해 놓았을 경우만 추가된다.

정확히 말하면 테스트를 통해 이 파일의 소스코드 중 실행된 부분을 % 로 환산한 값이다. 내 코드같은 경우는 테스트가 성공하면 예외코드를 실행하지 않기 때문에 100%가 나오지 않았다.

오른쪽에 빨간 부분은 테스트 코드가 실행되지 않은 부분이다. guard 문의 예외 상황은 체크되지 않았으며, Observable 의 nextHandler 를 활용하여 기능 테스트를 하지 않았다는 뜻이다.

이처럼, 테스트를 검증하기 위해 Test-Coverage 를 활용하면 좋다! 개인적으로는 85%까지는 Test-Coverage 를 맞추는 게 좋다고 생각한다. 예외 코드까지 테스트하기 위한 비용이 너무 많이 든다.

물론, 예외 코드까지 테스트한다면 더욱 좋은 서비스가 된다는 것을 부정하는 것은 아니다.

여기까지 길게 Unit-Test에 대해 설명하고, 아래는 UI-Test 와 개인적인 당부의 말이 나올 차례다.
아직까지 UI-Test 를 추천하기엔 많은 실습이 필요하므로, 스킵하는 것도 하나의 방법일 것이다.

Run 없이 UI-Test 로 View 개발하기

Xcode 에서 UI Test 는 "접근성 시스템"을 이용한다. 개발자가 작성한 테스트 코드를 토대로 Query 된 요소들을 검사하고, XCTest 프레임워크의 테스트 API 를 이용한다.

Xcode UI Test 로는 무엇을 할까? 나의 정의는 이것이다.

XCUIApplication 인스턴스에서 XCUIElementQuery 로 XCUIElement 를 불러온 후 API 를 이용해서 검증하는 것.

참고로 이 게시글에서는 기능적인 부분을 검사하지 않는다. 이유는 아래에 설명하려고 한다.

주요 클래스

  • XCUIApplication = Launch, Monitor, Terminate 가 가능한 테스트 앱의 프록시 객체. 쉽게 말해 앱 그 자체를 뜻한다고 보면 편하다.
  • XCUIElementQuery = Test 가 UI Element 를 인식할 기준을 설정한다. String 혹은 미리 정의된 프로퍼티 등을 이용하여 UI Element 를 불러올 수 있다. XCUIElementTypeQueryProvider 프로토콜 구현이 필요하다.
  • XCUIElement = Application 의 UI Element.

(주요 클래스보다 더 중요한) 레코딩 기능

이 기능은 실행 가능한 테스트 메소드 내에서 누를 수 있는 버튼이다. 사용방법은 정말 간단하기 때문에, 낮은 용량의 gif 로 바꾸다보니 저품질의 보충 자료를 아래와 같이 첨부한다.

처음에 클릭하는 동그랗고 빨간 버튼을 누르면 앱이 실행되면서 개발자가 하는 활동을 모두 코드로 기록한다. 기록을 중지하려면 빨간 버튼 자리에 있는 네모낳고 빨간 버튼을 누르면 된다.

테스트 코드를 작성할 때 어떻게 Query 를 해야 할지 감이 오지 않을 때는 이 기능을 사용해도 좋다.

로그를 읽는 방법

만약 내가 영어권 국가의 사람이었다면 '직관적인데?' 라는 생각을 했을지도 모르겠다.

0.00s - Test 시작.
0.03s - Set Up, Open, Launch, Wait.
2.17s - "회원가입" 버튼 탭하기. "회원가입" 버튼을 찾고 기다리기(idle).
2.23s - "회원가입" 버튼이 다른 요소에 영향을 끼치는지 확인
2.25s - 동기화 이벤트 실행. 기다리기(idle).
2.36s - Window - "idArea" Other, "passwordArea" Other, "passwordConfirmedArea" Other, "emailArea" Other, "nicknameArea" Other, "가입하기" Button 존재 여부 찾기
2.51s - 버튼 탭. 기다리기(idle). "Back" 버튼이 다른 요소에 영향을 끼치는지 확인.
3.57s - TearDown, Terminate

시간 별로 어떤 일이 일어나는지 보여주기 때문에 테스트 코드를 작성하거나, 테스트 실패 원인을 파악하는 데에 도움을 줄 수 있다.

accessibilityIdentifier, accessibilityLabel

결론만 말하자면 UI-Test 측면에서 accessibilityLabel 은 고려하지 않아도 된다.

accessibilityLabel 은 Voice Over 가 재생하는 문장 혹은 단어이다. nil 일 경우는 Voice Over 가 title 과 같은 값을 찾아 읽게 된다. 많은 분들의 의견이고, 본인도 동의하는 부분은 accessibilityLabel 을 설정하지 말자 이다. Voice Over 가 읽어야 하는 것은 화면에 나온 그대로의 Text 이다. 이것을 개발자가 따로 설정하는 것은 좋지 않다고 생각한다. 다국어 앱의 경우에도 문제가 복잡해진다.

그러니 accessibilityIdentifier 만 고려해 보도록 하자. accessibilityIdentifier 는 XCUIElementQuery 에서 identifier 로 인식된다. 이 값은 accessibilityLabel 과 다르게 개발자가 의도한 값을 넣어야 한다. 즉, UI Test 를 위해 직접 세팅해야 하는 것이다. XCUIElementQuery 의 identifier 로서 인식될 수 있는 값 중에는 staticTexts 도 있는데 accessibilityLabel 과 비슷하게 뷰에 표시된 값이므로 XCUIElementQuery 에 사용하기엔 적합하지 않다.

accessibilityIdentifier 만 알아도 되지만 accessibilityLabel 정도는 알아 둘 필요가 있을 것 같아서 언급해 보았다.

XCUIElementQuery로 XCUIElement 를 가져오는 방법

자주 사용하는 방법이라면 아래 두 가지가 있다.

  • 미리 정의된 프로퍼티를 이용한다.
    - 예시: XCUIApplication().textFields, XCUIApplication().tables.cells
  • accessibilityIdentifier 를 이용한다.
    - 예시: XCUIApplication().descendants(matching: .textField)["textField"]

위에서 말했다시피 accessibiltiyLabel 혹은 staticTexts 는 추천하지 않는다. 테스트 코드는 localizing 하지 않기 때문이다.

기능테스트를 지양하는 이유

기능테스트를 적극 채용하기 위해 시도를 많이 해 보았다. Unit-Test 에서 검증한 모델을 이용하여 실제 뷰와 잘 작동하는지 볼 수 있다면 너무 좋을 것 같다.

하지만 UI Test 는 자세한 기능 테스트를 진행하기엔 허들이 너무 높다는 느낌이 든다.

TextField로 scroll 하는 데 실패. AXAction(?)의 kAXScrollToVisibleAction을 AX 요소에 실행하는 중 에러 발생. 포인터 이벤트는 이 기기에서 지원하지 않음.

멋진 UI Test 기능테스트 코드가 나온다면 그것만 따로 포스팅 할 수 있도록 하겠다..

그럼 왜 해야하나?

UI Test 를 통해 기대할 수 있는 점은 다음과 같다.

  • View 가 정말 존재하는지 알아보기
  • 화면 이동이 정상적으로 이뤄지는지 알아보기

테스트를 돌린 후 Simulator 가 보여주는 화면을 통해 "이정도면 커밋해도 되겠어" 라는 생각이 든다면, 마우스로 Simulator 를 조작하거나, 손가락으로 앱을 조작해서 테스트하는 것 보다는 낫다고 생각한다.

그래도 위에서 얘기한 staticTexts 등을 써서 어떻게든 쿼리를 하면 간단한 기능(로그인, 버튼 누르기, 뒤로가기 버튼 누르기 등)은 충분히 가능하다.

UI-Test 시작하기

import XCTest
/// 실행해야 되는 테스트 클래스. test- 로 시작하는 메소드들이 유일하게 존재한다.
class issue_trackerUITests: XCTestCase {
    
    var app: XCUIApplication!
    
    override func setUp() {
        app = XCUIApplication()
        app.launch()
    }
    
    override func tearDown() {
        app.terminate()
    }
    
    func test_Login() throws {
        issue_trackerUITests_Login(app: app).doVisibleTest()
    }
    
    func test_SignIn() throws {
        issue_trackerUITests_SignIn(app: app).doVisibleTest()
        issue_trackerUITests_SignIn(app: app).doFunctionTest()
    }
    
    func test_MainView() throws {
        issue_trackerUITests_MainView(app: app).doFunctionTest()
        issue_trackerUITests_MainView(app: app).doVisibleTest()
    }
}

실행 가능한 테스트 코드. 클래스를 인스턴스화 한 후 do- 메소드를 실행하는 코드만 남겨서 직관적으로 만든다.

import XCTest
/// UITest를 위한 공통 클래스의 상위 클래스.
class CommonTestCase: XCTestCase {

    var app: XCUIApplication!
    
    // 기존의 XCUIApplication 객체를 그대로 사용할 수 있게 하였다.
    init(app: XCUIApplication) {
        super.init(invocation: nil)
        self.app = app
    }
    
    // 만약 버튼 등을 눌러서 이동해야 할 UI Test 라면 이 메소드를 override.
    // 주로 화면 이동을 위한 동작을 정의함.
    func prepareEachTest() { }
    // 다음 테스트를 위해 실행하고 싶은 작업이 있다면 이 메소드를 override.
    // 주로 sleep() 메소드를 이용해서 개발자가 화면을 확인할 수 있게 함.
    func tearDownEachTest() { }
    
    func doVisibleTest() { prepareEachTest() } // 뷰가 존재하는지 테스트하는 코드만 모아놓음.
    func doFunctionTest() { prepareEachTest() } // 뷰의 기능을 테스트하는 코드만 모아놓음.
}

위의 인스턴스화 한 클래스의 공통 상위 클래스. protocol 로도 가능하지만, 각 테스트마다 공통적으로 prepareEachTest() 를 실행하려고 클래스로 정의

import XCTest

extension XCUIApplication {
    func isButtonExists(id: String) -> Bool { // 해당 아이디로 query 되는 버튼이 앱에 보여지고 있는가
        self.buttons[id].firstMatch.exists
    }
    
    func isButtonExists(ids: [String]) -> [Bool] { // 위의 함수를 여러 아이디로 실행하고 싶을 경우 사용
        ids.map { self.buttons[$0].firstMatch.exists }
    }
    
    func isTextFieldExsits(id: String) -> Bool { // 해당 아이디로 query 되는 텍스트필드가 앱에 보여지고 있는가
        self.descendants(matching: .textField).firstMatch.exists
    }
    
    func isTextFieldExsits(ids: [String]) -> [Bool] { // 위의 함수를 여러 아이디로 실행하고 싶을 경우 사용
        ids.map { _ in self.descendants(matching: .textField).firstMatch.exists }
    }
}

extension XCUIApplication {
    func getViewUsing(id: String) -> XCUIElement { // 해당 아이디로 query 되는 뷰를 반환
        self.otherElements[id].firstMatch
    }
    
    func getViewUsing(ids: String...) -> [XCUIElement] { // 위의 함수를 여러 아이디로 실행하고 싶을 경우 사용
        var result = [XCUIElement]()
        
        for id in ids {
            let element = self.otherElements[id].firstMatch
            if element.exists {
                result.append(element)
            }
        }
        
        return result
    }
    
    func isViewExists(ids: [String]) -> [Bool] { // 해당 아이디로 query 되는 뷰가 있는지 반환
        ids.map { self.otherElements[$0].exists }
    }
}

각종 중복 코드를 더 명확히 표현하기 위해 타입 확장을 추가

import XCTest
final class issue_trackerUITests_Login: CommonTestCase {

    let textFieldIds = ["아이디", "패스워드"]
    let buttonIds = ["아이디로 로그인", "비밀번호 재설정", "회원가입", "간편회원가입"]
    
    override func tearDownEachTest() {
        sleep(2)
    }
    
    override func doVisibleTest() {
    	// super.doVisibleTest() 를 실행하는 것이 일반적이지만, 전처리 필요가 없기 때문에 실행하지 않는다.        
        for (index, result) in app.isTextFieldExsits(ids: textFieldIds).appenumerated() {
        	// XCTAssertTrue 는 XCTest 에 있는 API. result 인 bool 값이 true 가 아니면 테스트 실패.
            XCTAssertTrue(result, "[Error] \(textFieldIds[index]) not exsits")
        }
        for (index, result) in app.isButtonExists(ids: buttonIds).enumerated() {
            XCTAssertTrue(result, "[Error] \(buttonIds[index]) not exsits")
        }
        
        tearDownEachTest()
    }
    
    override func doFunctionTest() { }
}

로그인 화면에 요소들이 잘 보이는지 테스트하고 있다.

import XCTest

class issue_trackerUITests_SignIn: CommonTestCase {

    private let textFieldIds = ["idArea", "passwordArea", "passwordConfirmedArea", "emailArea", "nicknameArea"]
    private let buttonIds = ["가입하기"]
    
    override func prepareEachTest() {
        app.descendants(matching: .button)["회원가입"].tap()
    }
    
    override func tearDownEachTest() {
        sleep(2)
        app.navigationBars.firstMatch.buttons.firstMatch.tap()
    }
    
    override func doVisibleTest() {
        super.doVisibleTest()
        // XCTAssertNotNil 는 XCTest 에 있는 API. 표현식이 nil 이면 테스트 실패.
        XCTAssertNotNil(app.children(matching: .window).firstMatch.exists)
        
        for (index, result) in app.isViewExists(ids: textFieldIds).enumerated() {
            XCTAssertTrue(result, "[Error] \(textFieldIds[index]) not exsits")
        }
        for (index, result) in app.isButtonExists(ids: buttonIds).enumerated() {
            XCTAssertTrue(result, "[Error] \(buttonIds[index]) not exsits")
        }
        
        tearDownEachTest()
    }
    
    override func doFunctionTest() {
        super.doFunctionTest()
        
        let idField = app.getViewUsing(id: textFieldIds[0]).textFields.firstMatch
        // UITextField 는 secureTextFields/textFields 둘 중 하나로 인식된다. 잘못된 프로퍼티를 사용하면 테스트에 실패한다.
        let passwordField = app.getViewUsing(id: textFieldIds[1]).secureTextFields["비밀번호"] // staticTexts 를 사용하고 있다. accessibilityIdentifier 값으로 바꿀 필요가 있다.
        let passwordConfirmedField = app.getViewUsing(id: textFieldIds[2]).secureTextFields["비밀번호 확인"]
        let emailField = app.getViewUsing(id: textFieldIds[3]).textFields.firstMatch
        let nicknameField = app.getViewUsing(id: textFieldIds[4]).textFields.firstMatch
        
        idField.tap() // 텍스트 필드 탭
        idField.typeText("testios") // 텍스트 필드 'testios' 입력
        
        // 아래의 문구는 ID 중복체크에 통과했다는 의미이므로, 지정한 문구가 나와야 회원가입 기능 테스트가 가능하다.
        // waitForExistence 는 지정된 timeout 값만큼 뷰가 나오길 기다리고 뷰가 발견되면 true 아니면 false 를 반환한다.
        let isIdNotExists = app.getViewUsing(id: textFieldIds[0]).staticTexts["이상이 발견되지 않았습니다."].waitForExistence(timeout: 7.0)
        if isIdNotExists == false {
            return
        }
        
        passwordField.tap()
        passwordField.typeText("12341234")
        
        passwordConfirmedField.tap()
        passwordConfirmedField.typeText("12341234")
        
        app.scrollViews.element.swipeUp() // 앱에 보여지고 있는 scrollView 쿼리로 받아온 element 에 swipeUp 제스쳐를 전달/수행하게 한다.
        
        guard emailField.waitForExistence(timeout: 2.0) else { // 스크롤이 끝나기까지 2초를 주고 emailField 가 나오길 기다린다.
            XCTFail("[Error] emailField not exists")
            return
        }
        emailField.tap()
        emailField.typeText("testios@gmail.com")
        
        guard nicknameField.waitForExistence(timeout: 2.0) else {
            XCTFail("[Error] nicknameField not exists")
            return
        }
        nicknameField.tap()
        nicknameField.typeText("테스트아이오에스")
        
        app.descendants(matching: .button)[buttonIds[0]].tap() // 가입하기 버튼 탭
        
        XCTAssertTrue(app.alerts.element.waitForExistence(timeout: 4.0)) // 4초까지 UIAlertController 가 나오길 기다린다.
        app.alerts.buttons.element.tap() // 여기에 나올 UIAlertController의 버튼(UIAction) 은 하나이므로, 버튼을 눌러준다.
    }
}

회원가입 테스트 코드

import XCTest

class issue_trackerUITests_MainView: CommonTestCase {
    
    override func prepareEachTest() { }
    override func tearDownEachTest() {
        sleep(2)
        XCTAssertTrue(app.navigationBars.element.exists, "[Error] Screen Move Failed.") 
    }
    
    override func doVisibleTest() {
        super.doVisibleTest()
        tearDownEachTest()
    }
    
    override func doFunctionTest() {
    	// descendants 는 앱의 모든 children element 들을 가리킨다.
        // 이 코드는 그들 중 textField 에 해당하는 element 만 가져오는 코드이다.
        let idField = app.descendants(matching: .textField)["아이디"]
        let passwordField = app.descendants(matching: .textField)["패스워드"]
        idField.tap()
        idField.typeText("testios")
        passwordField.tap()
        passwordField.typeText("12341234")
        app.descendants(matching: .button)["아이디로 로그인"].tap()
        
        if app.alerts.element.waitForExistence(timeout: 2.0) { // UIAlertController 가 2초 이내에 등장했다는 것은 로그인이 실패했다는 증거.
            app.alerts.buttons.element.tap()
            XCTFail("[Error] Login Failed.")
            return
        }
        
        // 메인 뷰에는 UISegementedControl 이 존재한다. 5초 동안 UISegementedControl 를 기다린다.
        XCTAssertTrue(app.segmentedControls.element.waitForExistence(timeout: 5.0))
        app.segmentedControls.buttons.element(boundBy: 1).tap()
        app.segmentedControls.buttons.element(boundBy: 2).tap()
        
        tearDownEachTest()
    }
}

실제 Login 후 메인 화면으로 넘어가는지 확인하는 테스트 코드

생각보다 결과는 별거 없다.

결론

다음은 Martin Fowler 의 블로그에서 가져온 TestPyramid 라는 것이다(블로그가 있는 줄은 몰랐다). 테스트 속도를 토끼와 거북이로 표현하는 것은 아마 애플만 그러는 것이 아닌 것 같다. 저런 식으로 빗대어 표현하는 것은 WWDC 에서도 볼 수 있었기 때문이다.

그림과 같이 Unit Test 는 UI Test 에 비해 비용이 많이 든다. 비용이란 무엇일까? 난 (개발 공수+인건비+인프라 운영 비용) 라고 생각한다.

UI Test 에서는 우선 추가 작업량이 갑자기 늘어난다. UI Test 가 인식할 수 있게 accessibilityIdentifier 를 정해줘야 한다. staticText 라는 XCUIElementQuery 를 사용할 수도 있겠지만, 다국어 기능이 들어가게 되면 사용하기엔 부적합하다.

그렇지만 위의 문제는 운영을 하다보면 이런 단점들은 줄어들 것이다. 작업이 패턴화 되기 때문이다. 이런 건 프로젝트 팀에서 컨트롤 가능한 문제다(다행이다). 하지만, 테스트 실행시간은 컨트롤이 불가능하다. Simulator를 실행, 결과를 채집하는 일은 Apple Silicon 맥북이 아무리 강력하다고 해도 작업량 자체가 많다. 종종 화면을 필요에 따라 멈춰야 하는 순간도 있다.

결국 프로젝트 팀들의 결정이다. UI Test 가 비용 대비 효율적이라고 생각해서 도입했다가 Unit Test 만 진행하거나 Test 자체를 제외할 수도 있는 것이다.

개발자로서 업무를 보는 사람들은 모두 "프로페셔널" 해야 한다고 생각한다. 프로는 비즈니스를 고려할 줄 알아야 하지 않을까?

출처 모음

https://developer.apple.com/videos/play/wwdc2019/413/
https://medium.com/@paigeshin1991/ios-ui-testing-design-pattern-introducing-page-object-pattern-996158af3d2f
https://medium.com/wantedly-engineering/introducing-page-object-pattern-in-ios-74e46c664d26
https://blog.bitrise.io/post/making-xcode-ui-tests-faster-and-more-stable
https://www.raywenderlich.com/21020457-ios-unit-testing-and-ui-testing-tutorial

profile
plug-compatible programming unit

4개의 댓글

comment-user-thumbnail
2022년 9월 8일

ㅋㅋ 백 잘 계시죠?? 우연히 인터넷 서핑하다가 TDD글 발견해서 잘 읽고 갑니다 ㅎㅎㅎ 좋은 경험 공유해주셔서 감사해요~!! 테스트를 어떻게 하면 좀 더 유의미하게 사용할까 고민하던 차에 많이 배웠습니다!

1개의 답글
comment-user-thumbnail
2022년 9월 12일

백의 전투력이 올라가는 이야기였네요. 오호우...

UI 테스트까지 진행한 부분이 인상 깊습니다. (사실 무슨 말인지 잘모름)
열심히 학습하시는 것에 많은 자극 받고 갑니다

아래는 프리저 짤방입니다.

https://cdn.ppomppu.co.kr/zboard/data3/2016/0111/m_1452497658_%C7%C1%B8%AE%B4%F5%C0%FC%C5%F5%B7%C2.jpg

1개의 답글