뱅크샐러드 iOS팀이 숨쉬듯이 테스트코드 짜는 방식 2편 - 화면 단위 통합 테스트

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

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

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

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

화면단위의 통합테스트, Snapshot테스트

앱에서 만족시켜야 할 가장 중요한 요구사항은 당연히 주어진 정보를 디자인대로 잘 화면에 표현하는 일입니다. 스냅샷테스트는, 바로 이 화면이라는 출력물을 테스트합니다. 즉, 주어진 정보를 어떻게 표현해야 하는지에 대한 정답지로, “레퍼런스 이미지”를 미리 만들어두고, 우리의 코드가 같은 정보를 처리했을 때 레퍼런스 이미지와 정확히 똑같은 화면을 출력하는지를 픽셀단위로 비교하는 것이 스냅샷테스트의 원리입니다.

그래서 스냅샷테스트는 TDD로 작성하기 어렵습니다. TDD를 하려면, 결국 디자인 시안을 레퍼런스 이미지로 사용해야 하는데, 현실적으로 디자인 시안과 1픽셀의 오차도 없는 화면을 개발하는 것은 어려운 일입니다. 예컨대 디자이너가 하나의 폰트로 여러 플랫폼의 시안을 만든다면, 우리 OS에서 사용하는 폰트와 다른 폰트로 시안이 만들어질 수 있기 때문입니다.

한 편, 이런 1~2px의 오차는 사실 대부분의 유저가 눈치채기 어렵습니다. 만약 스냅샷테스트에서 아주 미묘한 1px의 오차로 테스트가 실패한다면, 그리고 이 테스트를 통과하게 만드는 비용이 크다면, 우리는 테스트를 무시해야 할까요? 그렇게 된다면 테스트는 그저 의미 없는 노이즈가 될 뿐입니다. 반대로 큰 비용을 들여서라도 그 마지막 1px까지 맞춰야 할까요? 그렇게 되면 제품을 만드는 비용이 지나치게 커질 수 있습니다. 스냅샷 테스트는 이러한 “사소한 픽셀 딜레마” 때문에 쉽게 도입되지 못합니다.

이 딜레마를 탈출하는 가장 쉬운 방법은, “허용범위”를 늘리는 것입니다. 예컨대 스냅샷 테스트에서 “1px까지는 봐준다”고 설정 할 수 있습니다. 근데 그러면 1.5px이 깨지면? 2px이 깨지면 어떻게 되어야 할까요? 허용범위를 늘리는 방법으로는 이 딜레마로부터 근본적으로 빠져나올 수 없습니다.

하지만 스냅샷테스트를, CI에서 돌아가는 검증테스트로 사용하지지 않고, 테스트의 보조수단으로 사용한다면 이런 딜레마로부터 탈출 할 수 있습니다. 예컨대 앱을 개발하다보면, 하나의 화면을 개발하기 위해 다음과 같은 흐름으로 작업하는 분들이 많습니다.

✏️ 코드 수정 → 🏘 프로젝트 전체 빌드 → 📱 앱 실행 → 🥾 여러 버튼을 클릭해 내가 작업한 화면으로 이동 → 🔎 화면 확인

이런 작업방식은 간단한 코드수정에도 시간이 오래 걸리게 만듭니다. 특히 해당 화면이 매우 깊숙히 숨겨져있는 화면이라면 더더욱 그렇습니다. 이 때, 스냅샷테스트를 활용하면, 다음과 같은 흐름으로 작업 할 수 있습니다.

✏️ 코드 수정 → 🏠 모듈 빌드 → 🧪 테스트 실행 → 🔎 화면 확인

즉, 다음 동영상과 같은 방식으로 작업 할 수 있는 것입니다.

기존 흐름으로는, 가장 간단한 코드 수정에서조차 화면 확인까지 최소 분 단위의 시간이 소요되지만, 테스트코드 기반의 방식으로는 초 단위의 시간만 소요됩니다. 그 뿐만 아니라, 뷰에서 표현할 데이터를 코드로 집어넣기 때문에, 내가 원하는 데이터를 원하는 시점에 넣을 수도 있습니다. 그래서 “3일 뒤에 카드값을 납부하는 시나리오”를 뷰가 잘 표현하는지 확인하기 위해, 내가 실제로 “3일 뒤에 카드값을 납부하는 카드”를 가지고 있을 필요가 없습니다.

이런 테스트는 개발단계에서도 유용하지만, QA단계에서 특히 유용합니다. 내가 아무리 테스트를 열심히 했더라도, 사람은 실수할 수 있기 때문에 여러 팀원들이 함께 하는 팀 차원의 QA가 꼭 필요합니다. 이 때 가장 시간을 많이 잡아먹는 테스트들이 역시 “시나리오별로 화면이 잘 그려지는지”에 대한 테스트들입니다. 기존에는 이런 테스트를 실행하기 위해 아주 많은 시간을 쏟았습니다.

그런데 이 때, 그 동안 작업해둔 테스트코드들을 실행시키고, 그 과정에서 생산된 이미지들을 XCTest의 첨부파일로 만들어 resultBundle로 관리하고, resultBundle에 모인 그 스크린샷들을 한 번에 팀원들과 공유한다면, 팀원들은 굳이 여러 시나리오를 테스트하기 위해 불필요한 수고를 할 필요 없이, 그저 그 스크린샷들을 쭉 검토해보면 되게 됩니다.

/// 카드섹션을 개발할 때 쓰는 테스트코드 
func testCardSectionView() async throws {
  // Given
  var data = CardSectionData()

  // When
  let view = CardSectionView(with: data)

  // Then
  let screenshot = view.screenshot()
  // 이 라인에 breakPoint를 걸면, `screenshot` 변수에 이미지가 어떻게 들어갔는지 확인 할 수 있습니다.

  // 만들어진 스샷들을 이 테스트의 첨부파일로 관리합니다
  let attachment = XCTAttacment(image: screenshot)
  attachment.lifetime = .keepAlways
  self.add(attachment)
} 
# runSnapshotTest.sh

# 위와 같은 테스트코드를 실행시키고, 
# 그 결과물인 스크린샷들을 뽑아내어 폴더로 관리합니다.

xcodebuild -workspace banksalad.xcworkspace \ 
  -scheme LocalAssetSnapshot \ 	# 현재 개발중인 화면들만 테스트하는, 그 내용물은 gitignore되는 scheme을 따로 만듭니다. 
  -sdk iphonesimulator \
  -destination "platform=iOS Simulator,name=iPhone SE (1st generation)" \
  -destination 'platform=iOS Simulator,name=iPhone 13 Pro Max' \
  -derivedDataPath build/ \
  -resultBundlePath resultBundle \ # 테스트 결과를 resultBundle경로에 저장합니다.
  test

xcparse screenshots \ # 테스트 결과에서 screenshot들만 parse합니다
   --test-plan-config \ # 이 때, test-plan과, 테스트가 실행된 기기 기준으로 폴더를 나눕니다.
   --model \
   resultBundle.xcresult \
   screenshotOutput # parse된 결과를 screenshotOutput 폴더에 저장합니다.
스크린샷폴더를 슬랙으로 공유하는 모습
위에서 만든 폴더를, 디자이너 및 PM에게 전달하면 대부분의 QA를 대체할 수 있습니다.
디자이너/PM입장에서는 위 파일을 받으면 이렇게 스크린샷을 확인 할 수 있습니다.

이렇게 해서 디자인 QA가 통과되면, 그 동안 개발용으로 만들었던 스냅샷들은 모두 명실상부하게 디자이너의 검증을 마친 “레퍼런스 이미지”가 되었습니다. 당연히 방금 작성한 코드로 생성된 이 레퍼런스 이미지를 정답으로 하는 스냅샷테스트는 언제나 성공할 수밖에 없습니다. 그래서 글 도입부에 소개한, “사소한 픽셀 딜레마”로부터 해방 될 수 있습니다.

func testCardSectionView() async throws {
  // Given
  var data = CardSectionData()

  // When
  let view = CardSectionView(with: data)

  // Then
  // 이제, 레퍼런스 이미지와 실제 출력물을 비교하는 assert 문을 작성 할 수 있습니다.
  assertSnapshot(matching: view.screenshot(), as: .image, named: testEnvironment)
} 

물론, 해당 화면의 디자인이 수정되게 되면, 이 스냅샷테스트도 깨질 수밖에 없습니다. 하지만 너무 걱정할 필요는 없습니다. 지금 소개한 작업흐름은 사실 TDD의 방식과 유사한 측면이 있기 때문입니다. 즉, 다음에 화면에 변경사항이 생기게 되면, 코드에 수정이 먼저 생기기보다는, 이 테스트코드들부터 다시 “작업 모드”로 들어가게 될 것입니다.

func testCardSectionView() async throws {
  // Given
  var data = CardSectionData()

  // When
  let view = CardSectionView(with: data)

  // Then
  // `작업모드`일 때에는, assertion을 주석처리하고, 
  // 대신 스크린샷을 테스트의 첨부파일로 넣는 코드를 추가합니다.
  let screenshot = view.screenshot()
  let attachment = XCTAttacment(image: screenshot)
  attachment.lifetime = .keepAlways
  self.add(attachment)

  // `작업모드`가 끝나면, 위의 스크린샷을 첨부파일로 넣는 코드를 제거하고, 
  // 아래 assert 문의 주석을 해제합니다.
  // assertSnapshot(matching: view.screenshot(), as: .image, named: testEnvironment)
} 

이렇게 작업모드에서 기존에 했던대로 작업이 끝나고, 다시 디자인 QA가 끝나고 새로운 레퍼런스 이미지가 만들어졌을 때, 이 테스트코드는 다시 CI에서 제 역할을 다 하게 됩니다.

그러나 이러한 스냅샷테스트코드에도 단점은 있습니다. 바로 문서로서의 기능은 하지 못한다는 점입니다. 스냅샷테스트에서는 출력물이 파일 형태로 저장되기 때문에, 코드만 읽어서는 그 출력물이 어떤 형태인지 알 수 없습니다. 테스트코드는 물론 버그를 방지하는 역할도 하지만, 그 이상으로 그 코드를 설명하는 문서의 역할을 해야한다고 생각합니다. 그리고 이미지 스냅샷테스트가 메꾸지 못하는 이 영역을 채울 수 있는 것이, 바로 다음에 소개할 AXSnapshot 테스트입니다.

높은 가독성과 적은 비용의 AXSnapshot테스트

많은 사람들이, “주어진 조건에서 원하는 정보가 원하는대로 출력되는지”를 테스트하기 위해 “ViewModel”을 테스트하라고 조언합니다. ViewModel에서 출력이 기대한대로 나오면, ViewModel과 View가 제대로 연결되어있다는 가정 하에, View에는 기대한 출력이 그대로 반영될 것이기 때문입니다. 이것은 맞는 말이고, 실제로 뱅크샐러드에서도 오랫동안 그렇게 테스트를 작성해왔습니다.

class CardListAndTimelineSectionViewModelTest: XCTestCase {
    func test보유한_카드_종류가_4_이상인_경우() {
        // Given
        var cardSection = V1_Financialasset_CardSection()
        cardSection.cards = 보유한_카드_종류가_4_이상인_경우()

        // When
        let viewModel = CardListAndTimelineSectionViewModel(in: cardSection)

        // Then
        XCTAssert(viewModel.top3MostUsedCardListItem.count == 3, "카드가 3개 이상 있을 경우, 사용 금액 순 상위 3개를 골라서 보여줘야 합니다")
        XCTAssert(viewModel.extraCardListCountText == "그 외 1개", "4개 카드 중, 3개를 직접 보여줬으니, 나머지 카드는 1개가 남았습니다")
        XCTAssert(viewModel.extraCardUsedAmountText == "40,000원")
    }
}

그런데 사실 우리는 여기서 한 걸음 더 나아갈 수 있습니다. ViewModel이 View에 정보를 잘 전달했다면, 그 정보가 View에 어떻게든 남아있지 않을까요? 그렇다면 View에 남겨진 그 정보들을 검사함으로써, 우리가 원하는 정보가 잘 보이는지를 확인 할 수도 있을 것입니다. 예컨대 다음과 같이 말이죠.

class CardListAndTimelineSectionViewTest: XCTestCase {
    func test보유한_카드_종류가_4_이상인_경우() {
        // Given
        var cardSection = V1_Financialasset_CardSection()
        cardSection.cards = 보유한_카드_종류가_4_이상인_경우()

        // When
        let viewModel = CardListAndTimelineSectionViewModel(in: cardSection)
        let view = CardListAndTimeLineView(with: viewModel)

        // Then
        // View의 속성을 검사하려면, 이처럼 접근제한자를 많이 풀어야만 합니다.
        XCTAssert(view.extraCardView.amountView.label.text == "40,000원")
    }
}

그런데 위와 같은 테스트코드가 가능하려면 결국 많은 접근제한자를 최소 internal 이상으로 풀어벼려야만 합니다. 은닉성이라는 객체지향의 원칙을 포기해가면서까지, “테스트 가능하게” 코드를 작성해야만 하는 것일까요? 아마 이 부분에서 많은 분들이 회의를 느껴서 View에 대해서는 테스트하지 않는 것이라고 생각합니다.

그런데, 만약 접근제한자를 전혀 수정하지 않고서도, View의 구체적인 속성들을 전혀 노출하지 않고서도, View에 우리가 제공한 정보가 잘 들어가있는지를 확인 할 수 있는 방법이 있다면 어떨까요? 그런 방법이 있습니다! 바로 View의 접근성 속성들이 잘 업데이트 되었는지를 확인하는 것입니다. 예컨대, 다음과 같이 아무리 복잡해보이는 화면이더라도, 결국 AssistiveTechnology(이하 AT)에게는 다음과 같은 “접근성 속성들 모음의 일차원 배열”로 보일 수밖에 없습니다. 그러니까 우리는 이 배열을 검사함으로써, 화면에 정보가 잘 전달되었는지를 확인 할 수 있습니다.


뱅크샐러드에서는 이렇게 AT에 노출된 정보들을 손쉽게 출력해 테스트 할 수 있도록 Ax(AccessibilityExperience)Snapshot 이란 도구를 만들어 사용중입니다. 이 도구를 활용하면, 어떤 뷰이던간에 다음과 같은 형태로 테스트 할 수 있습니다.

func testCardSectionDetail() async throws {
    let viewController = CardSectionDetail()
    await viewController.doSomeBusinessLogic()
    
    XCTAssert(
        viewController.axSnapshot() == """
        ------------------------------------------------------------
        카드
        버튼, 머리말
        두 번 탭하여 상세 화면으로 이동하세요
        Actions: 재시도
        ------------------------------------------------------------
        이번 달 사용한 금액, 400,000원
        버튼
        ------------------------------------------------------------
        이번 달 납부할 금액, 500,000원
        버튼
        ------------------------------------------------------------
        """
    )
}

이 같은 접근 방식에는 여러가지 장점이 있습니다. 먼저, 테스트가 더 좋은 문서가 되도록 만듭니다. ViewModel에서 출력들을 하나 하나 따로 테스트 할 때보다도, “화면 전체적으로 정보가 어떤식으로 구성되는지”를 한 눈에 이해하기 쉽습니다.

또한 View-ViewModel간의 연결이 확실하게 되었는지까지 테스트 할 수 있습니다. 연결 은 쉽게 구현할 수 있으면서도, 그 만큼 쉽게 누락되거나 고장날 수 있는 부분이기도 합니다. 생각해보면 우리가 마주하는 많은 버그들이 이 연결 부위에서 일어납니다. 일상생활에서도, 어떤 물건이 언제 고장나는지를 잘 생각해보세요. 책상 상판이 망가지는 일 보다는, 책상과 다리 사이의 연결부위에 문제가 생기는 경우가 훨씬 많습니다.

마지막으로, VoiceOver 등의 접근성 기능을 사용하는 유저들에게 의도된 경험을 제공하고 있는지를 확실하게 보장 할 수 있습니다. 접근성 지원은 대단히 중요하지만, 현실적으로 모든 테스터들이 충분히 테스트 할 수 있을거라 기대하기는 어려운 것이 현 시점 대한민국의 현실입니다. 게다가 내가 지금 약간의 개선을 일궜다고 해서, 내 후임자가 이 코드를 인계받아 지속적으로 개선을 이어나갈 수 있으리라 기대하기는 더더욱 어렵습니다. 따라서 충분한 시간동안 접근성 관련 이슈가 회귀하지 않도록 보장하기 위해서는, 자동화된 테스트가 필수입니다.

이러한 장점들이 있기 때문에, 뱅크샐러드에서는 AXSnapshot을 활용해 더 많은 기능들을 테스트 해나가고 있습니다. AXSnapshot은 오픈소스로 공개되어 있기 때문에, 누구라도 그 동작 원리를 보고 활용하고 개선 할 수 있습니다. 현재는 뱅크샐러드에서의 UseCase만을 중심으로 만들어졌기 때문에, 여러 한계들이 있을 수 있습니다. 그렇지만 이 글을 읽고 계신 훌륭한 iOS 개발자분들이 그런 빈 곳을 메꾸기 위해 PR을 날려주신다면 언제든 달려가가 성심성의껏 리뷰를 주고받으려 합니다. AXSnapshot테스트가 비단 뱅크샐러드에서 뿐만 아니라, 점차 업계에서 더 많이 활용되고 또 업계와 함께 성장해 나갈 수 있으면 좋겠습니다.

지금까지는 뱅크샐러드에서 사용중인, 통합적인 성격의 테스트들을 위주로 다뤄봤습니다. 다음 글에서는 TDD의 꽃, 스펙별 단위 테스트에 대해 본격적으로 다뤄보도록 하겠습니다.

참고자료

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

지원하기