[TIL] UI 테스트에 관해서

rbw·2022년 9월 13일
0

TIL

목록 보기
43/97
post-thumbnail

오늘의 공부 내용을 정리하기에 앞서서 읽으면 좋은 문장이라고 생각해서 들고 와봤습니당

"코딩하기 전에 하려는 작업을 이해해야 합니다. 우리가 사양이라고 하는 청사진 없이 다리나 집을 짓고자 한다면 신뢰할 수 없을 것입니다. 이것은 사양(명세)의 사용을 방해하는 소프트웨어의 문화에 대한 이야기입니다." - 레슬리 램포트(2013 튜링상 수상자)


오늘은 테스트에 관해서 조금 살펴보았는데 뱅크샐러드의 테크블로그 게시글을 참조하였습니다. 자세한 내용은 참조란에 뱅샐 블로그에서 확인 baram

통합 UI테스트

"가장 먼저 작성 해야 할 테스트"

현재 코드베이스에 테스트가 전혀 없다면 단위 테스트로 테스트를 하기는 어려울 것입니다. 그래서 이러한 코드 베이스에 테스트를 붙이려면, 기존 코드를 테스트 가능한 형태로 수정 해야 합니다. 그런데 수정을 해도, 버그가 생기지 않았는지를 확신할 수 있으려면 자동화 테스트가 필요합니다.

그런데 바로 그 자동화된 테스트를 만들기 위해서는 수정이 필요합니다.(ㅋㅋㅋ) 이런 딜레마를 해결하는 도구가 바로 통합 UI테스트입니다. 이 테스트는 어떤 식으로 앱을 만들었는지에 관해서는 상관이 없이 순수하게 테스트만 추가하는 것이 가능합니다.

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

하지만 이 테스트 비용은 매우 비싸다. 서버에 API를 요청하는 것도 실제 서버에 요청하고, 네트워크 환경, 개발서버 점검, 테스트 계정의 세션만료 등 실패할 여지도 많다. 따라서 꼼꼼하고 확실하게 테스트를 해야하는 영역에만 작성 해주는 것이 좋다 !

통합 UI테스트의 선행 작업

보통 앱의 접근성 경험이 제대로 갖춰있지 않다면 제대로 테스트가 안되는 경우가 많습니다. 통합 UI테스트에서 UITestRunner(실제로 테스트하는 친구)는 앱에서 제공하여 OS에 노출된 접근성 트리를 활용하여 버튼(예를 들어서, 버튼이라 할 때)을 찾고 그것과 상호작용하기 때문이다.

컴퓨터는 눈이 없기 때문에 시각장애인이 앱을 사용하는 것과 동일하게 생각하면 된다.

즉, VoiceOver로 사용할 수 없는 버튼은 테스트 코드로도 사용이 불가

추가로 테스트를 도입하기 전에 친해지는 단계로 LocalUITest를 사용하는 것을 추천하였다. 이것은 UITest이지만 gitignore에 작성 되어 있기 때문에, CI에는 돌아가지 않는 테스트를 의미한다.

또, 통합 UI테스트가 안정적으로 돌아가기 위한 조건 중 하나는 언제나 같은 종류의 시뮬레이터에서 실행 되어야 한다는 점을 명심해야 한다.

뱅샐에 테스트 예시에서는 iPhone SE1(제일 작은 기기)에서 유저가 설정한 폰트 크기 중 가장 큰 텍스트 크기 설정으로 테스트 하는 예를 보여 주었다.

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

이는 App에서 중요하게 생각하는 "주어진 정보를 바탕으로 화면을 잘 표현하는지"에 대한 테스트 입니다. 화면이라는 출력물을 테스트 합니다.

즉, 주어진 정보를 어떻게 표현하는지에 대한 정답지로 레퍼런스 이미지를 미리 만들어두고, 이 이미지와 우리 코드로 나오는 화면과 픽셀 단위로 비교하는 것입니다.

그래서 TDD로 개발이 어렵습니다. TDD를 하려면, 결국 디자인 시안대로 레퍼런스 이미지를 사용해야 하는데, 현실적으로 디자인 시안과 1px의 오차도 없이 만들기는 어렵습니다. (디자이너가 하나의 폰트로 여러 플랫폼의 시안을 만든다면 다른 폰트로 시안이 만들어 질 수 있기도 하다...)

1px의 오차를 고치기 위해 공수를 들이는 비용이 크다면 무시하는게 좋을지? 아니면 고쳐야 할지 이런 딜레마가 있습니다. 이를 "사소한 픽셀 딜레마"라고 부릅니다.

하지만 스냅샷 테스트를 CI의 검증테스트 말고, 테스트의 보조수단으로 사용한다면 이런 딜레마에서 벗어날 수 있습니다.

이를 설명하기 전에 스냅샷 테스트의 장점을 앱 개발의 흐름에서 살펴보자면

먼저 이런 흐름으로 앱을 개발하는 사람들이 존재 합니다.

  • 코드 수정 -> (전체)빌드 -> 앱실행 -> 내가 작업한 화면까지 이동 -> 화면 확인

이러한 흐름은 확인하는 시간이 오래 걸립니다. 이때, 스냅샷 테스트를 활용한다면 다음의 흐름과 같습니다.

  • 코드 수정 -> 모듈 빌드 -> 테스트 실행 -> 화면 확인

이런 흐름은 초 단위의 시간만 사용할 뿐입니다. 뿐만 아니라, 뷰에 필요한 데이터를 코드로 집언허기 때문에, 내가 원하는 데이터를 원하는 시점에 넣을 수 있습니다.

이제 사소한 딜레마에서 벗어나는 방법을 살펴보겠습니다.

스냅샷 테스트에서 생긴 이미지들을 XCTest의 첨부파일로 만들어서 resultBundle로 관리시키고 이러한 스크린샷들을 팀원들의 QA테스트에서 공유합니다.

/// 카드섹션을 개발할 때 쓰는 테스트코드 
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)
} 

이렇게 공유한 스크린샷들로 디자인 QA에 통과한다면, 사실 이러한 사진들을 앞서 말한 레퍼런스 이미지로 사용하면 됩니다. 따라서 이 이미지로 스냅샷 테스트를 하는 것은 언제나 성공하기 때문에 사소한 픽셀 딜레마에서 해방됩니다.

뭔가 꼼수 같이 느껴졌지만 발상을 전환하여 문제를 해결했다고 생각함니다. 디자이너가 결국 스크린샷을 확인하고 okay면 되긴 하니까 좋은듯

물론, 해당 화면 디자인이 수정되면, 이 스냅샷 테스트도 깨질 수 밖에 없습니다. 하지만 지금 소개한 작업 흐름은 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에서 새로운 레퍼런스 이미지가 만들어졌을 때, 이 TestCode는 CI에서 제 역할을 수행합니다.

하지만 SnapShot 테스트의 단점은 문서화가 힘들다는 점입니다. 이를 개선한 AXSnapshot 테스트에 관해서 알아보겠습니다.

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

viewModel의 테스트가 아닌 view에서의 테스트는 접근 제한자를 최소 internal까지 풀어야 한다는 우려(객체지향의 은닉성위배)때문에 잘 하지 않습니다. 하지만 이 문제를 해결하는 방법이 존재합ㄴ디ㅏ.

바로 view의 접근성 속성들이 잘 업데이트 되었는지를 확인 하는 것입니다.

아무리 복잡해도 AssistiveTechonology(AT)에는 접근성 속성들의 1차원 배열로 보입니다. 저희는 이제 이 배열을 검사하기만 하면 됩니다.

뱅샐에서는 접근성을 바탕으로 스냅샷 테스트 도구인 Ax(AccessibilityExperience)Snapshot을 만들어서 사용 중입니다.

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

이 방식은 화면 전체적으로 정보가 어떤 식으로 구성되는지를 이해하기 쉬워집니다. 또한 뷰와 뷰 모델의 연결이 되었는지도 테스트가 가능합니다.

스펙별 단위 테스트

뱅샐에서는 최대한 단순하고, 일관된 형태로 단위 테스트를 작성 할 수 있게 하는 도구를 TestUtility라는 모듈에서 관리하고 있고, 몇 가지 도구를 소개하겠습니다.

BaseTestCase

이는 모든 테스트 케이스가 Given, When, Then 문법으로 짜일 수 있는 구조를 제공합니다.

open class BaseTestCase: XCTestCase {
    /// 초기에 주입받아야 할 데이터를 지정합니다
    open func given(_ task: () -> Void) {
        task()
    }

    /// 발생해야 할 이벤트, 또는 메소드 호출등을 실행시킵니다
    open func when(_ task: () -> Void) {
        task()
    }

    /// 결과 값이 기대와 같은지 확인합니다
    open func then(_ task: () -> Void) {
        task()
    }
}

Given에서는 해당 시나리오를 재현하기 위해 필요한 데이터를 주입하거나 환경을 설정하고, When에서는 실제 해당 로직을 트리거 하는 코드를 실행시키고, Then에서는 그 로직의 출력물을 검사합니다.

RxTestCase

BaseTestCase를 상속 받아서 만든 클래스입니다. 뱅샐에서는 RxSwift로 대부분의 비동기 로직을 관리하므로 이를 통한 입력과 출력을 확인하는 테스트케이스가 필수입니다.

// 상세 코드는 뱅샐 블로그 3편에서 참고 바랍니다.
open class RxTestCase<T>: BaseTestCase { 
    ...
     open func when(observing events: Observable<T>, _ task: () -> Void) {
        self.eventsToObserve = events
        task()
        executeEvents()
    }
    ...
} 

위 케이스에서는 when(observing: Observer)메소드가 추가되었습니다. 이 메소드의 매개변수로 들어간 옵저버는 최종적으로 resultEvents에 이벤트를 전달해서, 테스트의 마지막 then에서는 언제나 이 resultEvents에 기대한 이벤트들이 쌓여있는지를 검사 할 수 있도록 했습니다.

이렇게 입력과 출력을 명확히 함으로써 가독성을 향상시키고, TestScheduler 관련 보일러플레이트 코드들을 최대한 줄여, 대부분의 경우, 테스트 코드가 3줄 내외로 작성 될 수 있도록 했습니다.

class SampleRxTestCase: RxTestCase<String> {

    func test버튼하나만_누르면_동작_안함() {
        given {
            viewModel = SampleViewModel(data: "Hello")
        }

        when(observing: viewModel.log) {
            createEvents([.next(0, Void())], to: viewModel.aButtonClicked)
        }

        then {
            XCTAssert(resultEvents.isEmpty)
        }
    }
    ...
}

또 중요한 로깅테스트나 화면전환 로직의 테스트는 RxTestCase를 상속하여 별도의 서브 클래스로 만들어서 관리를 하고 있습니다.

참고로 뱅샐에서는 모든 화면들을 enum으로 관리하고 있다고 한다. 따라서, Equatable 하지 않기 때문에 내부에 변수를 하나 추가해서 이것을 통해 비교한다고 함. 이때 해당 변수는 객체의 식별자와 같은 중요한 정보를 담고 있어야 합니다.

TDD를 향해서

TDD로 실제로 개발을 하려고 하면 막막한 부분이 많습니다. 따라서 처음부터 모든 것을 TDD로 개발 할 필요는 없고, TDD로 개발하기 쉬운 영역부터 구현을 진행하면서, 차근차근 TDD 영역을 확장해 나가는 것을 추천 합니다.

마치며, 자동화 테스트는 살아 숨쉬는, 가장 신뢰할 수 있는 스펙문서 입니다. 가장 중요한 스펙들일수록, 또 가장 놓치기 쉬운 스펙들일수록 테스트 코드로 표현되어야 하며, 그 표현은 극단적으로 쉬워야 합니다 .


참조

https://brunch.co.kr/@tilltue/62

https://blog.banksalad.com/tech/test-in-banksalad-ios-1/

profile
hi there 👋

0개의 댓글