Skip to content →

CI에서만 실패하는 테스트에 대처하기

기껏 열심히 테스트를 작성했는데, 내 컴퓨터에서는 잘 돌아가는 테스트들이 CI서버에서만 실패하는 일 만큼 절망스러운 일도 없습니다. 본능적으로 “내 코드나 테스트에는 문제가 없어! CI 서버에 뭔가 문제가 있는거야”라는 생각이 들지요. 물론 그 생각이 사실일 때도 있습니다. 잠시 후에 똑같은 테스트를 다시 돌렸는데 통과했다면, 잠시 CI서버에 모종의 장애가 있었을 수도 있어요. 하지만 같은 테스트가 지속적으로 실패하고 있다면, 그것은 개발자가 무언가를 놓쳤다는 신호입니다. 그리고 이는 아주 다행스러운 일입니다. 테스트가 제 역할, 즉 개발자가 놓친 어떤 버그의 요소를 미리 잡아내는 역할을 해낸 것이니까요. 이런 경우에는, CI에서만 실패하는 테스트를 무시하거나 없애버리고 싶은 유혹을 버리고, “CI 에서만 재현되는 버그” 를 해결하기 위해 노력해야 합니다.

그렇다면 CI에서만 재현되는 버그들은 어떻게 내 로컬 환경에서 재현하고 고칠 수 있을까요? 이는 결코 쉽지 않은 일입니다만, 다음의 제 노하우가 몇 몇 분들에게 도움이 될 수 있으면 좋겠네요.

Locale을 확인하자

CI서버에서만 재현되는 버그를 찾기 위해선, CI서버와 로컬 환경의 차이점을 파악해야 합니다. 그 차이점 중에 가장 두드러지는 것은, 아무래도 위치와 로케일 입니다. 그렇다면 위치와 로케일을 똑같이 만들려면 어떻게 해야 할까요? 내 컴퓨터를 들고 CI 서버가 위치해 있는 나라로 출장을 가서 테스트를 하면 될까요? 물론 그렇진 않습니다.

위치 및 로케일에 따라서 테스트의 결과가 달라질 수 있다는 점은 테스트 프레임워크를 만드는 대부분의 벤더가 고려하고 있는 사항입니다. Xcode에서도, Scheme->Edit Scheme->좌측의 Test 섹션으로 들어가면 각종 Options들을 확인 할 수 있습니다.

여기서 테스트 환경에서 앱의 언어 및 위치를 강제하게 되면 Locale.autoupdatingCurrent는 그에 걸맞는 Locale을 내놓게 됩니다.

CI서버의 언어 설정과 위치를 확인해보고, 그 언어와 위치를 위 메뉴에서 강제한 뒤에 로컬에서 테스트를 돌려보세요. 만약 Locale 과 관련된 코드를 테스트하는 테스트였다면 높은 확률로 실패를 재현 할 수 있을 겁니다.

저의 경험

예를 들어, 저는 NumberFormatter의 간단한 Wrapper를 만들고, 이를 아래와 같이 테스트 한 적이 있습니다.

// 테스트 되어야 할 코드
public extension Double {
	var formatted: String {
		NumberFormatter.localizedString(from: NSNumber(value: self), number: .decimal)
	}
}

// 테스트 코드
class NumberTest: XCTest {
	func testFormatter() {
		let number: Double = 1234.568
		XCTAssert(number.formatted == "1,234.568")
	}
}

이 테스트코드는 제 컴퓨터에서 아주 잘 돌아갔고, 심지어는 제 동료들의 컴퓨터에서도 잘 돌아갔습니다. 저는 별 것 아니지만, 그래도 코드베이스에 테스트코드를 추가했다는 기쁨에 우쭐해 있었죠. 하지만 어느덧 테스트가 점점 더 많아지고, 개개인이 생각 날 때마다 테스트를 돌리기보다는 CI에서 주기적으로 테스트를 돌리는 것이 합리적인 시점이 다가왔습니다.

저희 팀은 여러 CI서버를 비교한 끝에, 헝가리에 본부를 두고 있는 Bitrise라는 서비스를 이용하기로 했죠. 여러 시행착오 끝에 안정적으로 CI에서 테스트를 돌릴 수 있게 되었습니다. 그런데 이상한 일이 일어났습니다. 멀쩡하게 잘 돌아가던 위 테스트가 Bitrise의 컴퓨터에서만 계속해서 실패하던 것이었습니다.

지금에서야 너무나 명확한 원인이 보이지만, 그 당시에는 오리무중에 쌓인 기분이 들었습니다. Bitrise처럼 저렴한 서비스 말고 CircleCI나 Travis 처럼 비싼 CI 서비스를 이용했어야 했나라는 생각도 했었죠. 하지만 코드를 찬찬히 들여다보고, 특히 NumberFormatter.localizedDescription을 설명하는 문서를 보니 문제의 원인을 금방 알 수 있었습니다. 바로 localizedDescription 은 Locale별로 달라질 수 있다는 점을 간과한 것이지요. 팀원들이 모두 같은 Locale 환경에서 테스트를 진행했기 때문에 우리 컴퓨터에서는 문제가 나타나지 않았고, 헝가리의 컴퓨터에서는 Locale이 달랐기 때문에 소숫점을 찍는 방식을 다르게 표현했던 것입니다.

LocalizedDescription은 locale에 따라 달리지기 때문에 localizedDescription인 것입니다.

이런 문제를 해결하는 방식은 여러가지가 있습니다. 위에서 언급했던 것 처럼, Scheme의 테스트 옵션에서 SystemRegion 등을 강제하면 원하는 Locale환경에서 테스트를 할 수 있습니다.

한 편 저는 이런 종류의 wrapper를 아예 쓰지 않고 테스트 하지 않는 방식도 하나의 방법이라고 생각합니다. 위의 코드의 경우 Wrapper를 만들어서 줄일 수 있는 코드가 많지도 않고 오히려 코드의 의도를 불분명하게 할 뿐이니까요.

CI의 하드웨어 스펙과 소프트웨어 스택을 확실히 이해하자

CI 서버의 하드웨어 환경과 여러 소프트웨어들도 내 로컬환경과 다를 수 있습니다. OS 버전이 다를 수도 있고, 내 로컬 환경에 비해 하드웨어 자원이 훨씬 부족 할 수도 있죠. 대부분의 CI 서비스들은 사용 가능한 하드웨어 및 소프트웨어 스택을 문서로 잘 정리해 놓고 있습니다.

Bitrise의 경우에는 VM이 업데이트 될 때마다 깃헙 저장소에 문서가 업데이트 되도록 하고 있고, GithubAction 같은 경우도 별도의 문서 에 하드웨어 스펙과 소프트웨어 버전들을 명시하고 있습니다. 다른 여러 CI 서버들에도 비슷한 문서들이 있을 겁니다. 혹시 CI서버와 OS 버전이나 빌드 도구의 버전이 다르진 않은지, 아니면 CI서버의 능력을 초과할 정도로 RAM 을 굉장히 많이 소모하는 테스트를 수행하고 있지는 않은지 점검해 보세요.

또 Cocoapod으로 설치하는 여러 라이브러리들의 버전이 CI와 로컬환경에서 차이가 나는 경우도 많습니다. 똑같이 pod install 을 하면, 로컬에서는 Podfile.lock 이 lock한 버전을 쓰게 되기 때문에 가시적인 변화가 일어나지 않지만, CI에서는 Podfile.lock이 없는 경우가 많기 때문에, 언제나 최신버전의 라이브러리들을 다운받지요. 그렇기 때문에 CI와의 환경을 맞추기 위해서도, 또 버그 등이 수정된 가장 최신의 라이브러리들을 쓰기 위해서도 주기적으로 pod update 를 해주는 것이 필요합니다. 참조: pod update vs pod install

WebView를 포함한 ContainerVC를 테스트 할 때는 firstMatch API를 사용하자

아마 WebView가 결부된 테스트만 CI에서 실패하는 경우들이 있을겁니다. 제게도 이 상황은 정말 절망적이었습니다. 심지어 CI서버에서 UI테스트에 실패했을 당시에 찍혀진 스크린샷을 봐도, 제가 기대했던 UI모습이 그대로 찍혀있었거든요. 당시의 제 코드와 테스트코드는 아래와 같았습니다.

class LoginViewController: UIViewController {
	...
	@IBAction func showTermsAndConditions(_ sender: Any?) {
		let staticWebView = StaticWebView(url: "<https://약관페이이지> 주소")
		staticWebView.navigationItem.title = "회원 가입 약관"
		show(staticWebView)
	}
}

class TermsTester: XCUITest {
	...
	func testTerms() {
		let app = XCUIApplication()
		app.buttons["약관 보기"].tap()
		XCAssert(app.navigationBars["회원 가입 약관"].waitForExistence(timeout: 10))
		...
	}
}

위 코드를 보면, 실제로 웹뷰에 대해서 테스트 하는 코드는 없습니다. 그저 웹뷰가 담겨있는 ViewController의 navigationBar만을 테스트하고 있지요. 그런데 로컬 컴퓨터에서는 멀쩡히 잘 통과하던 이 테스트가, CI에만 올라가면 도무지 저 navigationBars[회원 가입 약관] 을 못 찾는 겁니다. 심지어 CI의 실패 스크린샷에는 멀쩡히 “회원가입 약관” 이라고 표시된 네비게이션 바가 있는데도요.

원인은, XCUITest 및 XCUIApplication의 작동 방식에 있었습니다. XCUIApplication은 우리가 테스트하려는 대상 앱(Target App)과 별도의 프로세스입니다. 그리고 우리가 XCUITest 안에서 XCUIApplication().navigationBars[“회원가입 약관”] 을 실행 하면, 다음과 같은 일이 일어납니다.

  • XCUIApplication으로 하여금 TargetApp에 대하여, 그 시점의 모든 Accessibility Elements의 Tree의 snapshot을 달라고 요청합니다.
  • TargetApp은 XCUIApplication에게 그 snapShot을 전달합니다.
  • XCUIApplication은 그 snapShot의 Tree를 샅샅이 훑습니다.
  • 그 뒤에 우리가 원하던 element가 그 Tree안에 있었다는 것이 밝혀지면, 테스트코드의 다음 라인을 실행합니다.

문제는 WebView가 그 snapShot안에 포함 되어 있을 경우, 이 snapShot의 크기가 커질 수 있고, 이는 적지 않은 메모리 부하를 일으키거나, query의 속도를 떨어뜨려 timeout을 일으킬 수 있다는 점입니다.

따라서 이런 경우에는, WWDC 2017년에 소개된 firstMatch API를 사용해야 합니다. FirstMatch 를 사용하면, TargetApp은 query문에 포함된 element가 발견된 즉시 그 때까지 만들어진 snapShot만을 XCUIApplication에 전달하기 때문에 snapShot의 크기가 작아지고,또 XCUIApplication도 훨씬 작은 Tree를 탐색하면 되기 때문에 탐색 속도도 훨씬 빨라집니다. 자세한 사항은, WWDC 2017년의 영상 What’s new in Testing 을 참고하시면 좋을 듯 합니다.

결론적으로 위의 테스트코드를 아래와 같이 고쳐서, Test는 훨씬 빨라지고 안정적으로 동작하게 되었습니다.

class TermsTester: XCUITest {
	func testTerms() {
		let app = XCUIApplication()
		app.buttons["약관 보기"].tap()
		XCTAssert(app.navigationBars["회원 가입 약관"].**firstMatch**.waitForExistence(timeout: 10)) // FirstMatch 사용
	...
	}
}

하지만 firstMatch API는 너무 남용해서는 안 됩니다. 원래 하나 있어야 할 버튼이 두 개 있게 된다면 UITest가 실패를 해야 하는데, firstMatch를 쓰게 되면, 하나 있는 element를 발견하자마자 탐색을 중단해버리기 때문에 실패해야 할 상황에 실패를 못하게 되는 상황이 발생 할 수 있으니까요.

마무리

ci에서 일어나는 문제를 디버깅하기란 굉장히 어렵습니다. 디버깅 할 수 있는 방법도 굉장히 제한되어있고, 적용한 해결책이 효과가 있는지를 확인하는데도 많은 시간을 써야 하는 경우도 많기 때문입니다. 만약 여러분이 CI를 처음 구축하는 입장이었다면, 그리고 아무도 그런 일을 시킨 적도 없었다면, 포기하고 싶은 마음이 많이 들거라고 생각합니다. 하지만 저는 절대 포기하지 마시라고 말씀 드리고 싶어요. 이렇게 해결하기 어려운 문제와 마주쳤을 때야 말로, 그 동안 놓치고 있었던 여러가지 문제를 발견 할 수 있는, 그리고 개발자로서 한 단계 성장 할 수 있는 기회라고 생각합니다. 이 글이 테스트와 CI를 도입하는데 어려움을 겪는 분들에게 조금이라도 도움이 되었으면 좋겠습니다.

참고문서

Published in Debugging

Comments

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.

%d 블로거가 이것을 좋아합니다: