테스트의 기초 – 1

해당 포스팅에서는 테스팅의 필요성과 XCode Unit Test와 일반적인 테스팅 가이드라인에 대해 설명하고자 합니다.

왜 테스트를 해야할까요? 라는 말의 iOS 엔지니어인 Jon Reid은 이렇게 말했습니다. 자동차는 브레이크가 있어 빨리 갈 수 있다고 합니다.

이말은 어찌보면 모순처럼 들릴 수 있습니다. 브레이크가 있어 운전자가 감속 할 수 있듯이, 자동차가 브레이크가 없으면 안전을 유지하기 위해 천천히 갈 수 밖에 없기때문이죠. 브레이크가 추가되어 우리가 빨리 주행할 수 있는 이유는 필요에 따라 언제든지 안전하게 멈출 수 있기 때문이라고 합니다.

이와 같은 논리가 테스트에도 적용이 됩니다. 테스트를 작성하여 보다 빠르게 작업 할 수 있습니다. 다시 말하자면 직관적이지 않습니다. 테스트를 작성하는 모든 작업이 궁극적으로 개발 기간과 노력을 늦추는 것처럼 들리기 때문입니다. 그러나 좋은 테스트를 작성하는 데 드는 비용은 궁극적으로 테스트에서 얻는 지속적인 혜택으로 인해 상쇄 시킬수 있습니다.


왜 테스팅을 해야할까요?

유명한 iOS 테스터 존 리드(jon Reid) 의 평소 가장 좋아하는 인용문은 “우리는 테스트를 통해 우리가 과감한 변화를 줄 수 있는 자신감을 가질 수 있기를 원합니다.”라고 말합니다. 프로젝트에서 작성하는 테스트 코드는 우리가 생각하는 바를 수행하지만 대신, 프로젝트에서 가지고 있던 낡은것(레거시 코드)을 두려워하지 않고 코드에 과감한 변화(리팩토링 및 재설계)를 줄 수 있다는 확신을 제공해야 합니다.

테스트에는 많은 종류가 있지만 근본적으로 두가지의 형태인, 컴퓨터에서 자동으로 실행할 수 있는 자동화 테스트와 수동 프로세스를 필요로하는 수동 테스트로 제공됩니다. 둘 다 모든 앱의 테스트 전략에서 중요한 부분을 차지하지만, 그 중 과감하게 변화를 줄 수 있는 것은 자동화된 테스트입니다. 우리가 바꾼 코드를 매초 수백 번의 테스트를 수행 할 수 있는 컴퓨터를 사용하면 프로그램의 모든 동작을 빠르게 확인할 수 있기때문입니다.

좋은 테스트와 좋은 설계를 구성하는데 매우 많은 도움을 줍니다. 여기서 말하는 좋은 설계란 지속가능한 프로젝트를 위한 목표입니다. 효율적으로 코드를 관리하고, 유지보수가 용이하며, 새로운 기능 확장이 가능하도록 되어야 한다는 목표라고 생각합니다. 우리 테스트가 프로세스 전반에 걸쳐 계속되어야하기 때문에 코드를 리팩토링을 할 수 있다는 것이며, 기본 동작의 세부 사항을 외부적 부수효과를 변경하지 않고 보다 효율적이고 유지 보수가 용이하며 확장이 가능하도록 변경해야 하기 때문이죠.

좋은 테스트는 우리가 과거에 고쳤던 버그가 고정되어 있음을 의미합니다. 실수로 우회하거나 회긔하여 버그가 다시 재발할 수가 없습니다. 테스트가 포함되지 않는 대형 앱이 있더라도 이러한 이유로 버그를 수정할 때마다 추가 할 수 있습니다.

마지막으로, 좋은 테스트는 실제로 좋은 문서가 되거나 그 중 일부가 될 수 있습니다. 작성한 각 테스트는 코드가 특정 입력을 받았을 때 코드가 특정 출력을 내보내기를 기대하는 직접적인 단언문입니다. 코드의 여러부분들이 어떻게 실행될 것으로 기대되는지 문자 그대로 기술합니다.

“왜 테스트인가?”라는 질문에 대한 대답이 좀 더 명확 해지길 바랍니다. 즉, 과감한 변화를 이끌어 내고 회귀를 조기에 발견하고 해결하며, 기대에 대한 포괄적이고 실제적인 설명을 제공함으로써 프로젝트는 좀 더 명확해집니다. 우리의 어플리케이션 기능을 가지고 테스트는 우리가 더 빨리 일할 수 있습니다. 회귀 문제는 손으로 발견하고 해결하는 것 예를 들면, 브레이크 없는 자동차를 주행하면서 우리는 위험을 무릎 쓰지 않는 것과 같이 우리가 변경 한 후에도 코드가 예상대로 작동 할 것이라고 확신 할 수 있습니다. 테스트가 자동차의 브레이크와 같이 지지하고 있기 때문이죠

다음 장으로 넘어가기전에 마지막 포인트에는 쉽게 빠뜨릴 수 있는 미묘한 추라는게 있습니다. 그것은 바로 테스트를 통해 코드가 예상대로 작동하지만 버그를 발견하는 것과는 별개의 일입니다. 결국 자동화 테스트는 예상치 못한 새로운 버그를 발견하기보다는 테스터가 제공하는 기대치만 검증합니다. Graham Lee가 말했듯이, 테스트는 “품질 보증이 아닌 품질 보증”을 제공합니다.


첫번째 테스트

일단 실습을 통해 알아봅시다.

이 구조체는 Hater라고 불리우며, 하나의 속성과 그 속성을 조작하는 2가지의 메서드를 정의합니다. 이 코드는 매우 단순하기 때문에 첫번째 테스트로는 적합하다고 봅니다.

/// file name: Hater.swift

struct Hater {
  var hating = false

  mutating func hadABadDay() {
    hating = true
  }

  mutating func hadAGoodDay() {
    hating = false
  }
}

먼저 Hater 구조체가 예상대로 작동하는지 테스트를 해봅시다. 어떻게 테스트 코드를 작성해야할까요?

먼저 스스로 고민후에 아래의 내용을 살펴 보시길 바랍니다.

  1. 초기화를 끝낸 시점에 hating이라는 속성은 false여야 한다.
  2. hadABadDay라는 함수를 호출하면 속성 hating은 true여야 한다.
  3. hadAGoodDay라는 함수를 호출하면 속성 hating은 false여야 한다.

그럼 위의 3가지의 테스팅 조건을 가지고 테스트 코드를 짜봅시다. (UnitTest와 메인 어플리케이션 스킴의 대한 연동방법에 대해서는 생각하겠습니다)

/// file name : FirstTests.swift

import XCTest
@testable import First

class FirstTests: XCTestCase {
  override func setUp() {
     ///...기본 템플릿 주석 생략...
  }

  override func tearDown() {
     ///...기본 템플릿 주석 생략...
  }

  func testHaterStartNicely() {
    ler hater = Hater()
    XCTAssertFalse(hater.hating)
  }
}

해당 코드를 작성후 cmd+U를 눌러 테스트를 해보세요. 우리가 작성한 코드의 녹색물이 들어오는지 확인합니다.


Xcode Unit Test의 해부학

적은 양이 코드만 작성 했음에도 우리는 이미 Swift 테스트의 몇 가지 중요한 부분을 보았습니다. 하나씩 살펴보죠

import XCTest

해당 구문은 XCTest라는 프레임워크를 가져옵니다. 이 프레임워크는 테스트의 모든 부분을 수행하는 Apple의 프레임워크입니다.

@testable import First

해당 구문은 첫 번째 모듈 (프로젝트 기본앱)을 가져오지만 다른 점은 바로 @testable를 키워드를 사용합니다. 기본적으로 코드의 모든 유형 및 속성은 internal이라는 엑세스 보호 수준을 사용하므로 기본 앱 모듈 내부의 코드만 읽을 수 있습니다. 테스트에서 여러 가지 문제가 발생하기 때문에 해당 어노테이션을 사용하면 테스트에서 앱의 나머지 코드와 동일한 코드와 같은 엑세스로 취급되기 때문에 엑세스 할 수 있습니다.

주의사항: 해당 액세스(private 또는 fileprivate)는 접근할 수 가 없습니다. 해당 코드는 비공개로 되어있으므로 테스트 해서는 안됩니다. 비공개 코드를 테스트하고 싶다면 아키텍처 설계를 다시한번 더 고려해볼 필요가 있습니다.

다음 FirstTests 라는 클래스가 있습니다. 종종 이러한 클래스와 테스트 클래스로 1:1로 매핑되지만 필수는 아닙니다. 모든 테스트 클래스는 XCTestCase를 상속해야합니다. 테스트를 실행할 때 XCode는 모든 XCTestCase의 하위클래스의 테스트 번들을 자동으로 검색하여 테스트 대상을 찾기 때문이죠

다음으로는 XCTestCase가 제공하는 setUp과 tearDown 메서드 입니다. 해당 메서드 호출은 Apple 템플릿이 제공하는 다른 예제 테스트를 제외하고 다음과 같은 테스팅 라이플 사이클은 다음과 같습니다.

  1. class func setUp()
  2. testHaterHatesAfterBadDay()
  3. tearDown()
  4. testHaterHatesAfterGoodDay()
  5. tearDown()
  6. setUp()
  7. testHaterStartsNicely()
  8. tearDown()
  9. class func tearDown()

테스팅 라이플 사이클에 대해 두 가지 질문이 있습니다. 첫째, 우리가 작성한 순서대로 테스트가 실행되지 않는 이유가 무엇일까요? 둘째, class setUp()에서 속성을 만들면 FirstTests어떨까요?

첫번째 질문의 대한 대답은 쉽습니다. 기본적으로 XCode는 알파벳 순서로 실행합니다.테스트 A가 먼저 실행되고나서 테스트B가 전달할 수 있다고 생각하기 쉽지만 그렇게 하지 마십시오. 그것은 한개의 테스트 결과가 실수로 다른 테스트가 오염이 되기 때문입니다.

두번째 질문에 대해서는 조금 더 복잡합니다. 하나의 단일 테스트를 실행하기 위해 Xcode는 테스트 클래스의 완전한 인스턴스를 생성합니다. 따라서 Apple의 세 가지 테스트와 Apple의 두 가지 예제 테스트의 경우 다섯 개의 FirstTests의 인스턴스를 생성하고 준비가 되면 위의 순서대로 테스트를 실행합니다.

테스트를 객체특성으로 작성하면 테스트가 실행되기 전에 이미 5개의 인스턴스가 메모리에 할당되었다는 것을 의미합니다. 그것은 또한 모든 테스트를 통해 기존의 방법을 계속 수행 할 것을 의미하며, 실제로 XCTestCase테스 인스턴스는 할당 해제하기보다는 테스트를 종료하기 때문에 사실상 파기가 된다는 보면 좋겠습니다. 따라서 테스트 개체가 중요한 설정 및 작업 중단 작업을 수행하는 경우에 발생하는 방법과 시기를 제어할 수 없기 때문에 격리(isolation) 테스트를 작성을 필요로 합니다.

따라서 테스트에서 사용하기 위해 객체를 만들려면 객체를 사용하는 동안 생성 setUp() 되고 tearDown() 내부에 객체의 해제와 같은 선택적 속성을 정의하는것이 좋습니다. 따라서 작업이 사용자의 제어에 따라 달라질 수 있습니다.

계속해서 알아보도록 하죠. 애플의 기본 테스트 템플릿에서는 testExample()testPerformanceExample() 그리고 우리가 작성했던 testHaterStartNicely 함수가 보이네요. 이 테스트 방법은 각각 세가지 공통점이 있습니다.

  1. 메서드앞에 test라는 단어로 시작합니다.
  2. 모두 매개 변수를 허용하지 않습니다.
  3. 리턴값이 Void로 끝납니다.

첫번째, test라는 키워드를 붙입니다. 반대로 말하면 test라는 키워드보다 더 앞에 다른 이름을 붙인다면 테스트를 안한다는 것입니다. 이러한 방법 또한 쓰입니다. 특히 “사용하지 않음(Unusable)”, “비활성(DISABLE)”, “건너 뛰기(SKIP)” 및 “사망(DEAD)”이라는 키워드가 모두 허용이 되고, 동료들중 누군가가 테스트코드가 부수효과로 인해 골머리를 앓고 있을 경우, 잠시나마 “손상됨(BROKEN)” 또한 사용하기도 합니다. 따라서 테스트 메소드의 이름을 DISABLED_testHaterHappyAfterGoodDay로 선택에 의해 테스트를 해제한 것으로 명확히 할 수 도 있습니다.

테스트 이름을 어떻게 정했는지는 팀과 당신에게 달려있지만, 테스트가 실패 하였을 경우, 테스트 메서드만 보고도 이유를 알 수 있도록 명확하게 만들어보십시오. 몇가지 공통된 스타일이 있습니다. 기본적인 스타일만 알아보도록 하죠.

testEmptyTableRowAndColumnCount() 실제로 Apple 문서에서 가져온 것입니다. 이 명명 스타일은 테스트를 많이하지 않아도 잘 작동하지만 코드에 대한 가설이 많아질 수록 해당 함수이름 또한 길어집니다. testEmptyTableRowAndColumnCountIsZero() 하지만 테스트로 기대하는 바를 쉽게 알 수 있습니다. 나쁘지 않습니다, 테스트 코드 자체에는 우리가 코드에 대한 기대를 반복하며 실제로 테스트 작성하는 것의 이점 중 하나입니다. 조금 더 나은 방법에 대해 설명해 봅시다.

우리가 테스팅 명명법을 잘 적용했는지 알수 있는 한가지 트릭이 있습니다. 그것은 테스트에서 일어나고 있는 행동을 나타내는 동사를 test 이외의 동사를 찾는 것입니다. 예를들어, testDeathStarFiredLaser() 또는 testMeaningOfLifeShouldBe42() 둘 다 우리가 기대하는 것을 분명히 하고, 특히 “명백하게” 주장해야합니다.

테스트 기술의 향상되면 Roy Osherove저서 Art of UnitTesting에서 사용하는 테스트용 명명 규칙을 채택하는 것이 유용 할 수 있습니다.

[ UnitOfWork_StateUnderTest_ExpectedBehavior ] XCode Unit test에 맞게 따라하면 다음과 같은 테스트 메서드를 만들 수 있습니다. test_Hater_AfterHavingAGoodDay_ShouldNotBeHating()

참고: PascalCase와 snake_case를 혼합하면 처음에는 머리를 아프게 할 수 있지만 적어도 UnitOfWork – StateUnderTest – ExpectedBehavior 분리를 한눈에 알 수 있습니다. test_Hater_afterHavingAGoodDay_shouldNotBeHating() 처럼 cameCase가 사용되는 것을 볼 수 있습니다.

어떤 사람들은 테스트 할 이름을 메소드 이름에 추가하여 테스트중인 메소드를 명확히합니다. 그게 효과가 있다면 좋겠지만, 리팩토링 중에 문제를 일으킬 수 있으므로 이방법은 사용하지 않습니다. Xcode는 관련 테스트의 이름을 아직 바꿀만큼 똑똑하지 않습니다.

해당 포스트에서는 많은 테스트를 작성하려고 합니다. 따라서 설명적인 테스트와 맹목적인 테스트의 군현을 잡으려고 노력할 예정입니다. long_andDescriptive_testNames()라는 규칙으로 작성하고 싶지만, 되도록 짧게 테스트 이름을 사용하려고 노력하겠습니다. 여러분 스스로가 프로젝트에 맞게 적합한 스타일을 찾기 위해 끊임없이 토론하고 시도해야 합니다.

다음으로는 Assert부분을 살펴봅시다. 우리가 작성할 테스트 맨 아랫줄에 대부분 위치하는 XCTAssertFalse() 와 XCTAssertTrue()는 테스트 수행 결과입니다. 즉, XCTest가 실행될 테스트를 수행 한 다음 결과를 사용하여 테스트가 성공했는지 여부를 결정합니다. 다음과 같은 상당수에 XCTAssert 메서드가 있습니다.

  1. 해당 결과는 값이 있다 – XCTAssertNotNil(), 또는 해당 값은 nil이다 – XCTAsserNil()
  2. 해당 결과의 두값은 같다 – XCTAssertEqual() 또는 같지 않다 – XCTAssertNotEqual()
  3. 해당 결과의 일부 표현식이 throw가 되지 않았다 – XCTAssertNoThrow() 또는 에러가 발생하지 않았다. XCTAssertThrowsError()
  4. 그외에 더 많은 XCTAssertion메서드가 있습니다.
  5. 마지막으로 직접 원하는 조건을 만들수 있는 XCTAssert() 메서드가 있습니다.

위의 XCTAssertion을 보다보면 모두 XCTAssertTrue로 사용할 수 있다고 생각할지 모릅니다. 즉, 이 두 코드는 동일한 결과를 얻기위한 메서드입니다.

XCTAssertTrue(2 == 3)
XCTAssertEqual(2, 3)

하지만, 두 가지 상황을 염두에 두어야합니다.

첫째, 훌륭한 테스트가 전반적인 문서 작업의 일부로 작동하므로 의도를 보다 분영하게 표현할 기회가 있다면 아래 식으로 사용하는것을 권장합니다.

둘째, 테스트가 실패 할 경우 두 개의 Assertion이 서로 다른 오류 메시지를 내봅니다.

  • XCTAssertTrue failed
  • XCTAssertEqual failed: (“2”) is not equal to (“3”)

보기에도 두번째 것이 오류가 더 직관적입니다. 따라서 두번째 방식으로 쓰는것이 훨씬 더 가치있다고 생각합니다. 따라서 XCTest가 제공하는 가장 정확한 Assertion을 사용하도록 항상 염두해 두어야 합니다.

모든 형태의 XCTAssert() 메서드를 사용하면 테스트가 실패 할 때 사용할 사용자 지정 메시지를 추가 할 수 있습니다. 이는 테스트가 실행되었을 때 어떤 일이 발생했는지 정확히 알 수 있는 최고의 장소이기 때문에 매우 권장됩니다. 예제 코드처럼 작성할 수 있습니다.

func testHaterStartsNicely() {     
   let hater = Hater()     
   XCTAssertFalse(hater.hating, "New Haters should not be hating.") 
}

다른 방법으로, 사람들을 체크되는 값의 단위 역할을 하는 메시지를 추가하여 다음과 같은 코드를 생성하는 경우가 있습니다.

XCTAssertEqual(correctLengthInMeters,testedLengthInMeters, "meters")

해당 테스트가 실패했을 경우, 다음과 같은 오류 메시지가 출력이 됩니다.

  • XCTAssertEqual failed: (“2”) is not equal to (“3”) – meters.

비록 작은 내용이지만, “meter(계량기)”를 추가하면 최소한 진행 상황에 대한 정보가 추가됩니다.

따라서, 일반적으로 이 두 형식 중 하나에서 마지막에 메시지를 문자열에 추가하여 사용하는 것이 좋습니다. 여기에 다룰 예제는 문자열을 추가하지 않고 유추하는건 어렵지 않기 때문에 다루지는 않겠지만, 여러분의 프로젝트에서는 직접 추가하길 권장드립니다.

마지막으로 살펴보고자 하는 내용은 테스트가 어떻게 구성되어 있는지 입니다.

func testHaterHappyAfterGoodDay() {
  var hater = Hater()
  /// 
  hater.hadAGoodDay()
  ///
  XCTAssertFalse(hater.hating)
}

해당 코드에 2개의 빈 주석 줄을 남겼습니다. 여기에는 현명하게 테스트 할 수 있는 방법 중 하나는 테스트를 세가지 상태로 나눌 수 있는 3A(Arrange, Act, Assert) 패러다임입니다.

  • Arrange: 테스트 준비가 된 상태로 만듭니다.
  • Act: 테스트하려는 코드를 실행합니다.
  • Assert: 해당 테스트에 대한 결과를 평가 합니다.

이것은 일반적으로 “given, when, then”으로 표현되며 테스트에서 주석으로 추가 된 용어를 보는 것은 매우 일반적입니다.

func testHaterHappyAfterGoodDay() {
  /// Given
  var hater = Hater()

  /// When
  hater.hadAGoodDay()

  /// Then
  XCTAssertFalse(hater.hating)
}

테스트 구성은 최소한의 Given(준비), When(행동), Then(단정)의 구조를 유지하는 것이 좋습니다. 즉, 무언가를 주장하기전에 더 많은 연기를 하고 다시 주장하는 방식을 사용하면 테스트가 실패할 때 혼란이 생기고 테스트가 격리되지 않을 확률이 높아집니다.

tip: 많은 테스터는 테스트 메서드에서 단 하나의 XCTest Assertion을 가져야한다고 생각하고 대신 여러 어설 션을 래핑하는 헬퍼메서드를 호출합니다. 개인적인 생각이지만 약간의 것들은 직관적이지 않을 수 있지만, 여러곳에서 동일한 Assertion을 사용할 때 많은 가치가 있다고 생각합니다.

0 Shares: