cleanUrl: /programming/debugging/ci-only-test-failure/

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

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

Locale을 확인하자

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

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

schme.png

여기서 테스트 환경에서 앱의 언어 및 위치를 강제하게 되면 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인 것입니다.

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

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

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

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