뱅크샐러드 iOS팀이 숨쉬듯이 테스트코드 짜는 방식 1편 - 통합 UI테스트

안녕하세요! 뱅크샐러드에서 iOS개발을 하고 있는 류성두입니다.

저는 상당히 오랜 시간 동안 테스트코드 작성을 대단히 어려운 것, 제한된 범위에서만 가능한 것, 개발 속도를 늦추는 것으로 생각했습니다. 무엇보다도, 테스트코드가 설령 장기적으로는 개발 속도를 빠르게 해줄지라도, 단기적으로는 생산성을 저하시킬 수밖에 없다고 생각했습니다.

하지만 뱅크샐러드에서, 이는 사실이 아니라는 것을 저는 차차 알게 되었습니다. 테스트코드는 쉽고 빠르게 작성할 수 있고, 앱의 기능 대부분을 테스트 할 수 있으며, 무엇보다도, 장기적인 차원에서 뿐만이 아니라 단기적인 차원에서 순간순간의 개발 속도도 훨씬 빠르게 만들어주었습니다. 테스트코드를 어떻게 사용하기에 그럴 수 있었는지, 뱅크샐러드에서 다년간의 시행착오를 통해 쌓아온 각종 노하우를, 총 3편의 글들을 통해 대방출합니다.

1편 - 앱 단위 통합 UI테스트 👈
2편 - 화면 단위 통합 테스트
3편 - 스펙별 단위 테스트 (발행전)

가장 먼저 작성할 테스트, 통합 UI테스트

테스트코드 도입의 딜레마

현재 코드 베이스에 테스트가 전혀 없다면, 아마 작성된 코드들이 단위테스트로 테스트하기 어렵게 되어있을 확률이 높습니다. 그래서 여기에 테스트를 붙이려면, 기존 코드를 테스트 가능한 형태로 수정해야 합니다. 그런데 수정을 해도 버그가 생기지 않았는지를 확신할 수 있으려면, 자동화된 테스트가 필요합니다. 그런데 바로 그 자동화된 테스트를 만들기 위해선 수정이 필요합니다! 닭이 먼저냐, 달걀이 먼저냐 하는 상황입니다. 테스트코드를 처음 도입하려는 사람이라면 아마 누구나 이런 딜레마를 마주해보셨을 겁니다.

이런 딜레마에서 탈출할 수 있게 하는 도구가, 바로 통합UI테스트입니다. 통합UI테스트는, 어떤 식으로 앱을 만들었는지, UIKit을 썼는지 SwiftUI를 썼는지의 여부와도 상관없이 작성할 수 있습니다. 즉, 기존 코드의 수정 없이, 순수하게 테스트만 추가하는 것이 가능합니다.

class testSubmit() {
  ...
  // button을 SwiftUI로 만들었든, UIKit으로 만들었든, 
  // 아래 테스트코드는 동작합니다
  app.buttons["제출"].tap()
  XCTAssert(
    app.navigationBar.staticText["제출완료"].waitForExistence(timeout:10)
  )
}

물론 애플에서도 이야기하듯, 통합UI테스트의 유지보수 비용은 비쌉니다. 예컨대 실제 앱을 대상으로 하는 통합UI테스트는, 당연히 서버에 API를 쏠 때도 진짜 서버에 쏩니다. 그런데 만약 네트워크 환경이 안 좋다면? 개발서버에 잠시 점검이 걸렸다면? 테스트에 쓰이는 계정으로 다룬 누군가가 다른 테스트를 하고 있어서, 내 테스트 계정의 세션이 중간에 만료되었다면? 이 처럼 통합 UI테스트에는 실패할 여지가 굉장히 많고 그만큼 다루기 어렵습니다. 그렇기 때문에 통합UI테스트만으로 앱의 모든 기능을 구석구석 수시로 테스트하는 것은 비현실적입니다.

그러나 이런 단점이 있다고 해서, 통합 UI테스트를 아예 버릴 필요는 없습니다. 그 비용을 지불하고서라도, 반드시 꼼꼼하고 확실하게 테스트해야 하는 영역, “이 기능이 망가지면 회사가 망한다”고 생각되는 영역들에 대해서 만큼은, 통합 UI테스트를 작성해야만 합니다. 이 영역이 통합적으로 테스트 된다면, 설령 “테스트를 가능하게 만들기 위한 수정”을 진행하는 과정에서 사소한 버그가 발생하더라도, 이 버그가 “회사를 망하게 하는 버그”가 되지 않도록 보장할 수 있기 때문입니다.

또한 이후에 단위테스트가 많이 도입되더라도, 가장 중요한 기능들만큼은 반드시 통합 UI테스트로 테스트되어야 합니다. 앱을 구성하는 작은 단위 하나하나가 아무리 튼튼하게 만들어져있어도, 그것들이 하나로 통합되는 과정에서 얼마든지 버그가 만들어질 수 있기 때문입니다.

하나하나는 문제없어도, 전체는 얼마든지 망가질 수 있습니다.

통합 UI테스트 도입의 선행작업

그런데 이 글을 읽고, 막상 통합 UI테스트를 작성하려고 시도했을 때, 마음처럼 되지 않는 경험을 하는 분들이 계실 겁니다. 예컨대 “제출”버튼을 누르기 위해 다음과 같이 코드를 작성했는데, 절대로 테스트코드에서 버튼이 눌리지 않는 경우가 있을 수 있습니다.

app.buttons["제출"].tap()
// 분명 화면에 "제출"버튼이 보이는데도
// `No matches found for ... `과 같은 오류가 뜨며 버튼을 누르지 못할 수 있습니다.

이런 경우는 대부분 앱의 접근성 경험이 제대로 갖춰지지 않아서 생긴 문제일 공산이 큽니다. 왜냐하면, 통합UI테스트에서 실제로 그 버튼을 누르는 UITestRunner는, 앱에서 제공해 OS에 노출된 “접근성 트리”를 활용해 버튼을 찾고 그것과 상호작용하기 때문입니다. 생각해보면 당연합니다. 컴퓨터에는 눈이 없으니까, 당연히 시각장애인이 앱을 사용하는 것과 같은 방식으로밖에 앱을 사용할 수 없습니다. 즉, VoiceOver로 사용할 수 없는 버튼은 테스트코드로도 사용할 수 없습니다.

만약 생각대로 통합UI테스트가 동작하지 않는다면, 먼저 VoiceOver를 익히고, VoiceOver로 앱을 충분히 사용할 수 있도록 하는 작업을 먼저 하셔야 합니다. 다시 위에서 언급한 닭-달걀 딜레마로 돌아온 것 같지만, 이 딜레마는 탈출하기 훨씬 쉽습니다. 왜냐하면 VoiceOver 지원 작업은 기존 코드에 영향을 끼칠 여지가 굉장히 적거든요. UI레이어 차원의 작업이면서도, 비주얼한 영역에 전혀 영향을 끼치지 않는, 완전히 새로운 레이어를 다듬는 작업이기 때문입니다.

아마 이 작업에 관한한, 가장 어려운 일은 VoiceOver를 켜보고 익히기로 마음먹는 일이 아닐까 합니다. 한 번도 사용해보지 않은 인터페이스를 새로 익히는 일은 두려운 일일 수밖에 없으니까요. 하지만 용기내어 딱 2~30분만 시도해보세요. 결코 어려운 일이 아니라는 것을 아실 수 있을 겁니다. 오히려 VoiceOver를 익히는 것은, iOS개발자로서 한 단계 도약할 수 있는 가장 쉬운 방법 중 하나라고 생각합니다.

VoiceOver가 처음이시라면, 애플에서 제공한 “VoiceOver, App testing beyond the Visuals” 영상이 좋은 시작점이 될 것입니다. 한국어로된 컨텐츠로는, “VoiceOver와 친해지기” 와 같은 영상이 있으니 참고해주시기 바랍니다.


통합 UI테스트의 도입

처음부터 CI에서 돌아가는 통합테스트를 작성하려고 하면 막막할 수 있습니다. 테스트코드를 어떻게 작성해야 하는지, 의도대로 동작하지 않을 때 디버깅은 어떻게 해야 하는지 등, 어느 정도 노하우가 쌓이지 않으면 최초에 동작하는 테스트를 어찌어찌 만들었더라도 이를 유지보수하기 어려울 수 있습니다.

그래서 뱅크샐러드에서는 먼저 LocalUITest를 통해 개발자들이 UI테스트와 쉽게 친해지도록 합니다. LocalUITest는 UITest이지만, 그 내용이 gitignore 되어서 CI에서는 돌아가지 않는 테스트를 말합니다.

LocalUITest를 소개하는 온보딩 문서
신규 개발자 온보딩 과정에서 LocalUITest와 그 사용법을 소개합니다

이 LocalUITest는 XCUITest와 친해질 수 있는 놀이터입니다! 어떤 식으로 활용하던지 완전히 개발자의 재량입니다. 예컨대 특정한 화면에 특정한 방식으로 접근했을 때만 재현되는 버그를 잡기 위해, 재현스텝을 자동화하는 용도로 사용할 수 있죠. 생각해보세요. 코드를 수정하고, 버그가 고쳐졌는지 확인하기 위해 매번 앱을 켜서 회원가입을 하고 자산을 연동해서 특정 화면에 들어가야 한다면, 얼마나 귀찮은 일이겠어요? 이런 귀찮음을 해결해주는 작은 도구로서 LocalUITest를 사용하다보면, XCUITest의 인터페이스에 차츰 익숙해질 수 있게됩니다. 그리고 이렇게 XCUITest 에 충분히 익숙해졌을 때, 나아가 익숙해진 동료가 충분히 늘어났을 때 본격적으로 CI에서 돌아가는, 가장 중요한 기능들을 점검하는 통합테스트를 작성하게 된다면 훨씬 안정적으로 관리 할 수 있게 될 겁니다.

class LocalUITests: XCTestCase {
    func testReproduceASSETS1323() throws {
        banksalad.launch()
        fastLogin(phoneNumber: "01012349901")
        
        openDeepLinkFromSafari("banksalad://someDeeplink")
        
        XCTAssert(banksalad.navigationBars["SomeFunction"].waitForExistence(timeout: 10))
        
        banksalad.tables.firstMatch.swipeUp()
        
        print(">>>>>>버그재현<<<<<<")
        print("👆 여기에 break point 걸기")
    }
}
LocalUITest의 운용사례. 뭘 검증하지 않더라도, 개발하면서 눌러야 할 버튼의 수를 줄여주는 것만으로도 XCUITest는 유용합니다

통합 UI테스트의 운용

뱅크샐러드에서는 가장 중요한 기능들을 테스트하는 통합UI테스트를 Smoke테스트라고 부르며, GithubAction을 활용해 4시간마다 한 번씩 동작하게 만들고 있습니다. 테스트가 실패할 경우에는, 테스트의 결과와 테스트가 실패한 시점의 스크린샷, 관련 코드 정보등이 포함된 resultBundle이 포함된 링크가 있는 슬랙이 날아오게 됩니다. 이 슬랙에는 그 주의 ios-monitoring 요원들이 태그되며, 태그된 개발자는 이 resultBundle의 내용을 파악해 문제를 해결하거나, 해당 기능을 주로 개발하는 개발자에게 문제를 공유하게 됩니다.

테스트 실패를 알리는 슬랙
실패를 확인하면, 모니터링조가 즉시 문제를 해결하거나 담당자를 배정합니다

이때 4시간이라는 간격은, 많은 시행착오를 통해 도출되었습니다. 통합테스트는 위에서 언급한 이유들 때문에 실패하기 쉬운데, 이 실패 알람이 지나치게 잦거나 뜸하면, 테스트 실패의 심각성에 팀이 점차 무뎌질 수 있습니다. 이런 현상이 발생하지 않도록, 다음 알람이 오기 전까지 충분히 테스트를 수정할 수 있어야 했고, 이것이 가능한 최소한의 간격이 저희 팀의 경우엔 약 4시간 내외였습니다. 테스트 주기의 적절한 간격은, 팀 내의 테스트코드 대응 역량을 비롯한 팀 내의 여러 상황에 맞추어 유기적으로 조절해야 한다고 생각합니다.

한 편, 통합UI테스트가 안정적으로 돌아가기 위한 조건 중 하나는, 언제나 같은 종류의 시뮬레이터에서 실행되어야 한다는 점입니다. 예컨대 어떤 기기에서는 바로 노출되는 버튼이, 어떤 기기에서는 스크롤을 한 번 해야 노출될 수도 있습니다. 이 때, simctl을 활용하면, 우리가 원하는 OS의 원하는 기기에 해당하는 시뮬레이터를 다음과 같이 쉽게 얻을 수 있습니다.

# getSimulatorMatchingCondition.rb
# 주어진 조건에 해당하는 시뮬레이터의 고유 ID를 출력합니다.
require "json"

deviceName = ARGV[0]
runTime = ARGV[1]

json = JSON.parse(%x(xcrun simctl list 'devices' -j))
devices = json["devices"]["com.apple.CoreSimulator.SimRuntime.iOS-#{runTime}"]

if devices == nil
    puts "Error: 해당 OS를 만족하는 시뮬레이터가 설치되어 있지 않습니다"
else
    filteredDevices = devices.filter { |item| item["name"] == deviceName }

    if filteredDevices.empty?
        puts "Error: 해당 이름을 만족하는 시뮬레이터가 설치되어 있지 않습니다"
    else
        puts filteredDevices[0]["udid"]
    end
end

# 사용예시 
# ruby getSimulatorMatchingCondition.rb "iPhone SE (1st generation)" "15-0"
iOS 개발자 컴퓨터에 node는 안 깔려 있을 수 있어도, ruby는 반드시 깔려있기 때문에, json을 처리하는 도구들도 웬만하면 ruby를 활용해 만듭니다
# runSmokeTest.sh
# CI에서 SmokeTest를 실행하도록 하는 도구입니다.

# 마지막으로 실행했던 테스트 결과가 남아있다면 제거합니다
rm -rf resultBundle
rm -rf resultBundle.xcresult

SIMULATOR_NAME="iPhone SE (1st generation)"
SIMULATOR_ID=$(ruby getSimulatorMatchingCondition.rb "$SIMULATOR_NAME" "15-0")
BUNDLE_ID="com.banksalad.dev"
BOOTED=$(xcrun simctl list 'devices' | grep "$SIMULATOR_NAME (" | head -1  | grep "Booted" -c)

open -a simulator

if [ $BOOTED -eq 0 ]
then
  # 아직 우리가 원하는 시뮬레이터카 안 켜져 있으면 켭니다.
  xcrun simctl boot $SIMULATOR_ID
fi

# 테스트를 실행하기 전에, 기존에 설치된 앱을 제거합니다.
xcrun simctl uninstall $SIMULATOR_ID $BUNDLE_ID

# 실제 테스트를 실행합니다.
set -e -o pipefail 
xcodebuild -workspace banksalad.xcworkspace \
  -scheme banksaladUITests \
  -sdk iphonesimulator \
  -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
  -testPlan SmokeTests \
  -derivedDataPath build/ \
  # 👇 테스트 실패시, CI에 업로드하기 편하도록, resultBundle이 저장되는 위치를 지정합니다.
  -resultBundlePath resultBundle \ 
  test

여기서 한 걸음 더 나아가, 테스트를 실행할 때, 다음과 같이 launchArgument에, “유저가 설정한 폰트 크기” 정보를 추가해, 언제나 “가장작은 사이즈의 폰(iPhone SE 1세대)에서, 가장 큰 글씨를 쓰는 사람”을 기준으로 테스트를 실행합니다.

class SmokeTest: XCTestCase {
  let banksalad: XCUIApplication = {
    let app = XCUIApplication(bundleIdentifier: "com.banksalad.dev")
    app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
    app.launchArguments += ["IS_UI_TESTING"]
    return app
  }()

  func testOnboarding() {
     banksalad.launch()
     ...
  }
}
iPhoneSE 1세대에서 가장 큰 글씨를 사용하는 화면
테스트는 가장 큰 글씨를 기준으로 실행합니다.

어차피 자동으로 하는 테스트, 기왕이면 평소에 손으로 테스트하기 어려운 가장 엣지케이스를 중심으로 테스트한다면, 우리가 평소에 눈치채지 못하고 지나칠 수 있었던 버그들을 발견할 기회를 대단히 높일 수 있습니다.

하는 김에 로깅 테스트까지

마지막으로, 뱅크샐러드의 통합UI테스트에서는, 단순히 “기능을 사용할 수 있는지”의 여부만을 테스트하지 않고, 해당 기능을 사용하면서 우리가 남기는 로그들이 기대된 대로 잘 기록되는지를 함께 테스트합니다. 로깅은 회사에서 의사 결정을 하는데 사용되는 주요 잣대이기 때문에, 그 로그가 누락된다던가 혹은 반대로 중복으로 기록될 경우, 대단히 잘못된 의사 결정을 야기 할 수도 있습니다. 이렇게 중요한 기능이지만, 일상적으로 앱을 사용 할 때에는 그 기능의 존재를 인지하기 어렵기 때문에, 매뉴얼 테스팅 과정에서 쉽게 누락되기도 합니다. 그렇기 때문에, 가장 중요한 기능들에 관한 로그가 잘 기록되는지는 반드시 자동화된 테스트로 검증되어야 합니다. 그래서 어차피 그 중요한 기능들을 자동으로 테스트하는 김에, 그 테스트의 맨 마지막에 그동안 기대한 대로 로그가 잘 쌓였는지를 검사한다면 최소한의 노력으로 최대의 효과를 볼 수 있습니다.

그런데 통합UI테스트는 앱과 완전히 다른 별도의 프로세스에서 돌아가기 때문에, 앱에서 로그를 잘 남기는지를 직접적으로 관측할 수 없습니다. 이 문제를 해결하기 위해, 우리는 앱 내의 개발자센터에 “이 세션에서 발생한 로그”들을 모아보고 검색할 수 있는 기능을 만들었습니다. 그래서 자동화테스트의 맨 마지막 국면에서는, 이 개발자센터 기능으로 들어가, 검색창에서 기대한 이벤트들을 검색하고, 이벤트들이 기대한 숫자만큼 기록되었는지를 확인합니다. 이런 기능은 자동화 테스트를 위해 만들어졌지만, 만들고 보니 수동으로 하는 로깅 테스트도 훨씬 쉬워져, 이벤트 관련 코드의 품질을 전반적으로 끌어올릴 수 있었습니다.

/// 해당 UI요소 안에서 특정 문자열을 포함하는 UI요소들의 갯수를 출력하는 extension 입니다. 
/// 이벤트 로그 결과 등을 검사할 때 유용합니다.
extension XCUIElement {
    func numberOfLabelsContaining(text: String) -> Int {
        let predicate = NSPredicate(format: "label == %@", text)
        return staticTexts.matching(predicate).count
    }
}

extension XCUIElementQuery {
    func numberOfLabelsContaining(text: String) -> Int {
        let predicate = NSPredicate(format: "label == %@", text)
        return self.containing(predicate).count
    }
}

마치며

지금까지 앱 전반에 걸친 통합테스트 작성에 대해 얘기해봤습니다. 통합UI테스트는, 코드베이스에 테스트가 전혀 없는 상황에서 처음으로 도입하기에는 적절한 테스트이지만, 사실 글 도입부에 언급한 것처럼, 다양한 영역을 커버하고 개발속도를 빠르게 해주는 테스트는 아닙니다. 하지만 도입 과정에서 개선한 접근성 경험은, 다음 글에서 다룰 화면 단위의 통합테스트를 가능하게 합니다. 그리고 바로 이 화면 단위 통합 테스트부터가, 쉽고 빠른 테스트의 시작이라고 할 수 있습니다.

참고자료

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기