A Splash of EarlGrey – Toptal Talent 앱을 테스트하는 UI
게시 됨: 2022-03-11작업을 보다 효율적이고 빠르게 하기 위해 테스터로서 할 수 있는 가장 중요한 일 중 하나는 테스트 중인 앱을 자동화하는 것입니다. 매일, 때로는 하루에 여러 번 전체 테스트 세트를 실행하고 앱 코드에 푸시된 모든 변경 사항을 테스트해야 하기 때문에 수동 테스트에만 의존하는 것은 실현 가능하지 않습니다.
이 기사에서는 iOS Toptal Talent 앱 자동화의 맥락에서 Google의 EarlGrey 1.0이 우리에게 가장 적합한 도구임을 확인하기 위한 우리 팀의 여정을 설명합니다. 우리가 그것을 사용하고 있다는 사실이 EarlGrey가 모든 사람을 위한 최고의 테스트 도구라는 것을 의미하지는 않습니다. 단지 우리의 필요에 맞는 도구일 뿐입니다.
EarlGrey로 전환한 이유
수년에 걸쳐 우리 팀은 iOS와 Android 모두에서 다양한 모바일 앱을 구축했습니다. 처음에는 단일 테스트 세트를 작성하고 다른 모바일 운영 체제에서 실행할 수 있는 플랫폼 간 UI 테스트 도구를 사용하는 것을 고려했습니다. 먼저 가장 인기 있는 오픈 소스 옵션인 Appium을 사용했습니다.
그러나 시간이 지남에 따라 Appium의 한계는 점점 더 분명해졌습니다. 우리의 경우 Appium의 두 가지 주요 단점은 다음과 같습니다.
- 프레임워크의 의심스러운 안정성으로 인해 많은 테스트 조각이 발생했습니다.
- 비교적 느린 업데이트 프로세스가 작업을 방해했습니다.
Appium의 첫 번째 단점을 완화하기 위해 테스트를 보다 안정적으로 만들기 위해 모든 종류의 코드 조정 및 해킹을 작성했습니다. 그러나 두 번째 문제를 해결하기 위해 우리가 할 수 있는 일은 없었습니다. iOS 또는 Android의 새 버전이 출시될 때마다 Appium이 따라잡는 데 오랜 시간이 걸렸습니다. 그리고 버그가 많아 초기 업데이트를 사용할 수 없는 경우가 많았습니다. 결과적으로 우리는 종종 이전 플랫폼 버전에서 테스트를 계속 실행하거나 작동하는 Appium 업데이트가 제공될 때까지 테스트를 완전히 꺼야 했습니다.
이 접근 방식은 이상적이지 않았으며 이러한 문제와 함께 자세히 다루지 않을 추가 문제로 인해 대안을 찾기로 결정했습니다. 새로운 테스트 도구의 최고 기준은 안정성 향상 과 업데이트 속도 향상이었습니다. 약간의 조사 후에 우리는 각 플랫폼에 대해 기본 테스트 도구를 사용하기로 결정했습니다.
그래서 Android 프로젝트에서는 Espresso로, iOS 개발에서는 EarlGrey 1.0으로 전환했습니다. 돌이켜 생각해보면 이것이 잘한 결정이었다고 말할 수 있습니다. 각 플랫폼에 대해 하나씩 두 가지 다른 테스트 세트를 작성하고 유지 관리해야 하기 때문에 "잃어버린" 시간은 너무 많은 불안정한 테스트를 조사할 필요가 없고 버전 업데이트에 대한 중단 시간이 없기 때문에 만회하는 것 이상이었습니다.
로컬 프로젝트 구조
개발 중인 앱과 동일한 Xcode 프로젝트에 프레임워크를 포함해야 합니다. 그래서 우리는 UI 테스트를 호스팅하기 위해 루트 디렉토리에 폴더를 만들었습니다. 테스트 프레임워크를 설치할 때 EarlGrey.swift
파일을 생성해야 하며 그 내용은 미리 정의되어 있습니다.

EarlGreyBase
는 모든 테스트 클래스의 상위 클래스입니다. 여기에는 XCTestCase
에서 확장된 일반 setUp
및 tearDown
메서드가 포함되어 있습니다. setUp
에서 우리는 대부분의 테스트에서 일반적으로 사용되는 스텁을 로드하고(나중에 스텁에 대해 자세히 설명함) 테스트의 안정성을 높이는 것으로 확인된 일부 구성 플래그도 설정합니다.
// Turn off EarlGrey's network requests tracking since we don't use it and it can block tests execution GREYConfiguration.sharedInstance().setValue([".*"], forConfigKey: kGREYConfigKeyURLBlacklistRegex) GREYConfiguration.sharedInstance().setValue(false, forConfigKey: kGREYConfigKeyAnalyticsEnabled)
페이지 개체 디자인 패턴을 사용합니다. 앱의 각 화면에는 모든 UI 요소와 가능한 상호 작용이 정의된 해당 클래스가 있습니다. 이 클래스를 "페이지"라고 합니다. 테스트 방법은 페이지에서 별도의 파일 및 클래스에 있는 기능별로 그룹화됩니다.
모든 것이 표시되는 방식에 대한 더 나은 아이디어를 제공하기 위해 앱에서 로그인 및 비밀번호 찾기 화면이 어떻게 보이고 페이지 개체로 표시되는지 보여줍니다.

이 기사의 뒷부분에서 로그인 페이지 개체의 코드 내용을 제시할 것입니다.
맞춤형 유틸리티 방법
EarlGrey가 테스트 작업을 앱과 동기화하는 방식이 항상 완벽한 것은 아닙니다. 예를 들어 UI 계층 구조에 아직 로드되지 않은 버튼을 클릭하려고 하여 테스트가 실패할 수 있습니다. 이 문제를 피하기 위해 요소와 상호 작용하기 전에 요소가 원하는 상태로 나타날 때까지 기다리는 사용자 지정 메서드를 만들었습니다.
다음은 몇 가지 예입니다.
static func asyncWaitForVisibility(on element: GREYInteraction) { // By default, EarlGrey blocks test execution while // the app is animating or doing anything in the background. //https://github.com/google/EarlGrey/blob/master/docs/api.md#synchronization GREYConfiguration.sharedInstance().setValue(false, forConfigKey: kGREYConfigKeySynchronizationEnabled) element.assert(grey_sufficientlyVisible()) GREYConfiguration.sharedInstance().setValue(true, forConfigKey: kGREYConfigKeySynchronizationEnabled) } static func waitElementVisibility(for element: GREYInteraction, timeout: Double = 15.0) -> Bool { GREYCondition(name: "Wait for element to appear", block: { var error: NSError? element.assert(grey_notNil(), error: &error) return error == nil }).wait(withTimeout: timeout, pollInterval: 0.5) if !elementVisible(element) { XCTFail("Element didn't appear") } return true }
EarlGrey가 자체적으로 수행하지 않는 또 다른 작업은 원하는 요소가 표시될 때까지 화면을 스크롤하는 것입니다. 이를 수행하는 방법은 다음과 같습니다.
static func elementVisible(_ element: GREYInteraction) -> Bool { var error: NSError? element.assert(grey_notVisible(), error: &error) if error != nil { return true } else { return false } } static func scrollUntilElementVisible(_ scrollDirection: GREYDirection, _ speed: String, _ searchedElement: GREYInteraction, _ actionElement: GREYInteraction) -> Bool { var swipes = 0 while !elementVisible(searchedElement) && swipes < 10 { if speed == "slow" { actionElement.perform(grey_swipeSlowInDirection(scrollDirection)) } else { actionElement.perform(grey_swipeFastInDirection(scrollDirection)) } swipes += 1 } if swipes >= 10 { return false } else { return true } }
우리가 식별한 EarlGrey의 API에서 누락된 다른 유틸리티 메소드는 요소를 계산하고 텍스트 값을 읽는 것입니다. 이러한 유틸리티의 코드는 GitHub: here 및 here에서 사용할 수 있습니다.
API 호출 스터빙
백엔드 서버 문제로 인한 잘못된 테스트 결과를 방지하기 위해 OHHTTPStubs 라이브러리를 사용하여 서버 호출을 모의합니다. 홈페이지의 문서는 매우 간단하지만 GraphQL API를 사용하는 앱에서 응답을 스텁하는 방법을 제시합니다.
class StubsHelper { static let testURL = URL(string: "https://[our backend server]")! static func setupOHTTPStub(for request: StubbedRequest, delayed: Bool = false) { stub(condition: isHost(testURL.host!) && hasJsonBody(request.bodyDict())) { _ in let fix = appFixture(forRequest: request) if delayed { return fix.requestTime(0.1, responseTime: 7.0) } else { return fix } } } static let stubbedEmail = "[email protected]" static let stubbedPassword = "password" enum StubbedRequest { case login func bodyDict() -> [String: Any] { switch self { case .login: return EmailPasswordSignInMutation( email: stubbedTalentLogin, password: stubbedTalentPassword ).makeBodyIdentifier() } } func statusCode() -> Int32 { return 200 } func jsonFileName() -> String { let fileName: String switch self { case .login: fileName = "login" } return "\(fileName).json" } } private extension GraphQLOperation { func makeBodyIdentifier() -> [String: Any] { let body: GraphQLMap = [ "query": queryDocument, "variables": variables, "operationName": operationName ] // Normalize values like enums here, otherwise body comparison will fail guard let normalizedBody = body.jsonValue as? [String: Any] else { fatalError() } return normalizedBody } }
스텁 로드는 setupOHTTPStub
메소드를 호출하여 수행됩니다.

StubsHelper.setupOHTTPStub(for: .login)
모든 것을 합치다
이 섹션에서는 위에서 설명한 모든 원칙을 사용하여 실제 종단 간 로그인 테스트를 작성하는 방법을 보여줍니다.
import EarlGrey final class LoginPage { func login() -> HomePage { fillLoginForm() loginButton().perform(grey_tap()) return HomePage() } func fillLoginForm() { ElementsHelper.waitElementVisibility(emailField()) emailField().perform(grey_replaceText(StubsHelper.stubbedTalentLogin)) passwordField().perform(grey_tap()) passwordField().perform(grey_replaceText(StubsHelper.stubbedTalentPassword)) } func clearAllInputs() { if ElementsHelper.elementVisible(passwordField()) { passwordField().perform(grey_tap()) passwordField().perform(grey_replaceText("")) } emailField().perform(grey_tap()) emailField().perform(grey_replaceText("")) } } private extension LoginPage { func emailField(file: StaticString = #file, line: UInt = #line) -> GREYInteraction { return EarlGrey.selectElement(with: grey_accessibilityLabel("Email"), file: file, line: line) } func passwordField(file: StaticString = #file, line: UInt = #line) -> GREYInteraction { return EarlGrey.selectElement( with: grey_allOf([ grey_accessibilityLabel("Password"), grey_sufficientlyVisible(), grey_userInteractionEnabled() ]), file: file, line: line ) } func loginButton(file: StaticString = #file, line: UInt = #line) -> GREYInteraction { return EarlGrey.selectElement(with: grey_accessibilityID("login_button"), file: file, line: line) } } class BBucketTests: EarlGreyBase { func testLogin() { StubsHelper.setupOHTTPStub(for: .login) LoginPage().clearAllInputs() let homePage = LoginPage().login() GREYAssertTrue( homePage.assertVisible(), reason: "Home screen not displayed after successful login" ) } }
CI에서 테스트 실행
Jenkins를 지속적 통합 시스템으로 사용하고 모든 pull 요청에서 각 커밋에 대한 UI 테스트를 실행합니다.
fastlane scan
을 사용하여 CI에서 테스트를 실행하고 보고서를 생성합니다. 실패한 테스트에 대해 이러한 보고서에 스크린샷을 첨부하는 것이 유용합니다. 불행히도 scan
은 이 기능을 제공하지 않으므로 사용자 지정해야 했습니다.
tearDown()
함수에서 테스트가 실패했는지 감지하고 실패한 경우 iOS 시뮬레이터의 스크린샷을 저장합니다.
import EarlGrey import XCTest import UIScreenCapture override func tearDown() { if testRun!.failureCount > 0 { // name is a property of the XCTest instance // https://developer.apple.com/documentation/xctest/xctest/1500990-name takeScreenshotAndSave(as: name) } super.tearDown() } func takeScreenshotAndSave(as testCaseName: String) { let imageData = UIScreenCapture.takeSnapshotGetJPEG() let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true) let filePath = "\(paths[0])/\(testCaseName).jpg" do { try imageData?.write(to: URL.init(fileURLWithPath: filePath)) } catch { XCTFail("Screenshot not written.") } }
스크린샷은 Simulator 폴더에 저장되며 빌드 아티팩트로 첨부하려면 여기에서 가져와야 합니다. 우리는 Rake
를 사용하여 CI 스크립트를 관리합니다. 테스트 아티팩트를 수집하는 방법은 다음과 같습니다.
def gather_test_artifacts(booted_sim_id, destination_folder) app_container_on_sim = `xcrun simctl get_app_container #{booted_sim_id} [your bundle id] data`.strip FileUtils.cp_r "#{app_container_on_sim}/Documents", destination_folder end
주요 내용
iOS 테스트를 자동화하는 빠르고 안정적인 방법을 찾고 있다면 EarlGrey를 찾으십시오. Google에서 개발 및 유지 관리하며(더 말해야 합니까?) 여러 면에서 오늘날 사용 가능한 다른 도구보다 우수합니다.
테스트 안정성을 높이기 위해 유틸리티 메서드를 준비하려면 프레임워크를 약간 수정해야 합니다. 이를 위해 사용자 정의 유틸리티 메소드의 예부터 시작할 수 있습니다.
백 엔드 서버에 예상한 모든 테스트 데이터가 없기 때문에 테스트가 실패하지 않도록 스텁 데이터를 테스트하는 것이 좋습니다. OHHTTPStubs
또는 유사한 로컬 웹 서버를 사용하여 작업을 완료하십시오.
CI에서 테스트를 실행할 때 디버깅을 더 쉽게 하기 위해 실패한 사례에 대한 스크린샷을 제공해야 합니다.
우리가 아직 EarlGrey 2.0으로 마이그레이션하지 않은 이유가 궁금할 것입니다. 여기에 간단한 설명이 있습니다. 새 버전은 작년에 출시되었으며 v1.0보다 몇 가지 향상된 기능을 약속합니다. 불행히도 EarlGrey를 채택했을 때 v2.0은 특별히 안정적이지 않았습니다. 따라서 아직 v2.0으로 전환하지 않았습니다. 그러나 우리 팀은 향후 인프라를 마이그레이션할 수 있도록 새 버전에 대한 버그 수정을 간절히 기다리고 있습니다.
온라인 리소스
GitHub 홈페이지에 있는 EarlGrey의 시작하기 가이드는 프로젝트의 테스트 프레임워크를 고려하고 있다면 시작하고 싶은 곳입니다. 거기에서 사용하기 쉬운 설치 가이드, 도구의 API 문서, 테스트를 작성하는 동안 사용하기 쉬운 방식으로 모든 프레임워크의 방법을 나열하는 편리한 치트 시트를 찾을 수 있습니다.
iOS용 자동화 테스트 작성에 대한 추가 정보는 이전 블로그 게시물 중 하나를 확인할 수도 있습니다.