제목: Keeping XCTest in sync on Linux

갱신이력:
2017.03.30 이 이슈에대해 프로세스를 추적하는 버그를 참조한 꼬릿말 추가
2017.03.30 코드 생성을 사용하는 솔루션을 대신해주는 appendix 를 추가
2017.03.31 생성된 파일의 타겟 패스를 명세하는 Sourcery 양식을 수정. 이것은 수공업의 리네이밍 단계를 절약해준다.

스위프트는 크로스-플랫폼이지만, 애플 플랫폼과 다른 OS에서 다르게 동작하는데, 주로 두가지 이유가 꼽힌다.
  • Objective-C 런타은 애플 플랫폼에서만 가능하다.
  • Foundation과 다른 core library들은 애플OS가 아닌 것의 구현이 따로 되어있다. 이 의미는 Foundation API는 macOS/iOS와 리눅스에서 다른 결과를 만들수도 있고, 혹은 그냥 아직 완전히 구현되지 않았을 수도 있다.
그러므로 라이브러리를 어떤 애플 플랫폼의 특정 기능에 의존하지 않게 짜면, macOS/iOS 그리고 리눅스의 코드를 테스트하기 좋은 전략이 된다.

리눅스에서 테스트 디스커버리
어떤 유닛 테스트 프레임워크는 실행해야 테스트를 찾을수 있게 해놓았따. 애플 플랫폼에는 XCTest 프레임워크가 있는데, 이것은 모든 테스트 수트와 테스트 타겟에있는 메소드를 돌기위해 Objective-C 런타임을 사용한다. Objective-C 런타임은 리눅스에서 사용할 수 없고 스위프트 런타임은 최근에 동등한 기능이 부족하기 때문에, 리눅스의 XCTest는 실행하고자하는 테스트 목록을 명시저으로 제공하도록 개발자들에게 요구한다.

allTests 프로퍼티
이 방식으로 실행하는 방법(스위프트 패키지 매니저에서 만들어진 컨밴션)은 여러분의 각 XCTestCase 서브클래스에 allTests라는 이름의 프로퍼티를 추가하는 방법이다. 이것은 테스트 함수와 그 이름들의 배열을 반환한다. 예를들어 한 테스트를 가지고있는 클래스는 아래처럼 생겼을 것이다.
// Tests/BananaKitTests/BananaTests.swift
import XCTest
import BananaKit
class BananaTests: XCTestCase {
   static var allTests = [
       ("testYellowBananaIsRipe", testYellowBananaIsRipe),
   ]
   func testYellowBananaIsRipe() {
       let banana = Banana(color: .yellow)
       XCTAssertTrue(banana.isRipe)
   }
}

LinuxMain.swift
이 패키지 매니저는 LinuxMain.swift라는 이름의 또다른 파일을 만드는데, 이것은 애플 플랫폼이 아닌 곳에서 동작시키는 테스트 실행자처럼 행동한다. 여기에는 XCTMain(_ :)을 호출하는데 이것은 모든 테스트 수트들의 리스트가 있다.
// Tests/LinuxMain.swift
import XCTest
@testable import BananaKitTests

XCTMain([
   testCase(BananaTests.allTests),
])

수작업의 유지보수는 잊어버리기 쉽다
이 방법은 두곳에서 수작업의 유지보수가 필요하기 때문에 분명 이상적이지 않다.
  1. 새로운 테스트를 추가할때마나 그 클래스의 allTests를 반드시 추가해야한다.
  2. 새로운 테스트 수트를 생성할때마다, LinuxMain.swift에서 XCTMain 호출을 반드시 추가해야한다.
이 두가지 단계 모두 잊어버리기 쉽다. Even worse, 불가피하게 그중 하나를 잊어버릴때, 뭔가 잘못되었음이 조금도 명확하지 않다. 테스트들이 리눅스에서는 통과할 것이고, macOS와 리눅스에서 실행되는 테스트 수를 손수 비교하지 않으면, 몇몇 테스트가 리눅스에서는 돌아가지 않는다는 점을 인지하지 못할수도 있다.

나에게는 이런일이 자주 발생했으므로 이것에대해 뭔가 조처를 취하기로 했다.

리눅스 테스트를 빠트리는 것에대해 보호하기
유지보수 단계중 잊어버릴때 자동으로 테스트 수트를 실패하게 만드는 매커니즘을 만들어보자. 각 XCTesetCase 클래스(와 그들의 allTest 배열)마다 아래 테스트를 추가할 것이다.
class BananaTests: XCTestCase {
   static var allTests = [
       ("testLinuxTestSuiteIncludesAllTests",
        testLinuxTestSuiteIncludesAllTests),
       // Your other tests here...
   ]

  func testLinuxTestSuiteIncludesAllTests() {
       if os(macOS) || os(iOS) || os(tvOS) || os(watchOS)
           let thisClass = type(of: self)
           let linuxCount = thisClass.allTests.count
           let darwinCount = Int(thisClass
               .defaultTestSuite().testCaseCount)
           XCTAssertEqual(linuxCount, darwinCount,
               "\(darwinCount - linuxCount) tests are missing from allTests")
       endif
   }
   // Your other tests here...
}
이 테스트는 Objective-C에의해 발견된 테스트 수와 allTest 배열에 항목 수를 비교하여 두 수가 일치하지 않음을 발견하면 실패를 띄워버릴 것이다. 정확히 우리가 원하던 것이다.

(Obj-C 런타임에서의 의존성이라는 의미는 애플 플랫폼에서만 동작하는 테스트라는 뜻이다. 리눅스에서는 컴파일되지 않을 것이고, #if os(macOS) ... 블럭으로 감싼 이유가 바로 그것이다)

allTests에 테스트를 추가하는 것을 잊어버렸을때 실패한 테스트
이것을 테스트 하기위해 다른 테스트를 추가하자, 이 테스트는 allTests 갱신을 깜빡한 경우이다.
import XCTest
import BananaKit

class BananaTests: XCTestCase {
   static var allTests = [
       ("testLinuxTestSuiteIncludesAllTests",
        testLinuxTestSuiteIncludesAllTests),
       ("testYellowBananaIsRipe", testYellowBananaIsRipe),
       // testGreenBananaIsNotRipe is missing!
   ]

   // ...

   func testGreenBananaIsNotRipe() {
       let banana = Banana(color: .green)
       XCTAssertFalse(banana.isRipe)
   }
}
이 테스트을 macOS에서 돌리면 우리의 보호 테스트가 실패할 것이다.

allTests 배열에 테스트를 추가하는 것을 까먹었기 때문에, 보호 테스트가 실패하고있다allTests 배열에 테스트를 추가하는 것을 까먹었기 때문에, 보호 테스트가 실패하고있다


나는 이것을 매우 좋아한다. 분명히, 모든 테스트마다 allTests 배열에 담고 싶을때만 동작할것이다. 즉 위에서 한것처럼 조건부의 컴파일에서 어떤 다윈- 혹은 리눅스 테스트를 감싸야할 것이다. 나는 이것이 많은 코드베이스의 한계를 만족시킬 것이라 믿는다.

LinuxMain.swift를 보호하기
다른 플랫폼은 어떤지 LinuxMain.swift가 완료되었다고 검증할 수 있을까? 이것은 좀 더 힘들다. LinuxMain.swift는 실제 테스트 타겟의 부분이 아니므로(될 수 없다), XCTMain으로 전달되는 내용은 쉽게 확인할 수 없다.

내가본 유일한 솔루션은 Run Script 빌드 단계를 테스트 타겟에 추가하고, LinuxMain.swift에 스크립트 파싱 코드를 넣어, 테스트 타겟에서 테스트 수트의 수에 배열의 항목을 비교한다. 아직 시도해보진 않았지만, 꽤 복잡하게 느껴진다.

업데이트: 코드 생성을 사용한 솔루션으로 appendix를 사용할 수도 있다.

결론
새 테스트에도 불구하고, 여전히 잠재적으로 잊어버릴 수 있는 두가지 때문에 완벽해보이진 않는다. 새로운 XCTestCase 클래스를 생성할때마다 반드시 아래를 행해야한다.
  1. 새 클래스에 testLinuxTestSuiteIncludesAllTests 테스트를 복사하여 붙여넣어야한다.
  2. LinuxMain.swift를 갱신해야한다.
그럼에도 불구하고, 새로운 테스트라는게 가장 일반적인 케이스(현재 테스트 수트를 새로운 테스트를 추가하고는 allTests 배열을 갱신하는 것을 까먹는 케이스)를 커버하므로 현재 상태 보단 상당히 낫다고 생각한다.

스위프트의 반영 능력이 더 강력해지는것을 기다릴 수 없다. 이런 모든것이 불필요하게 되면 좋겠다.


Appendix: Sourcery로 코드 생성
최근에 반복되는 주제로 되는 것처럼 보이는 것에서, Krzysztof Zabłocki가 짚어주었는데, 그의 훌륭한 코드 생성 툴인 Sourcery로 리눅스 테스트 기반을 유지보수할 수도 있다. 이것은 훌륭한 대체물이고 꽤 쉽게 세팅할 수 있다. 아래에 그 방법을 설명해 놓았다.

1. Sourcery 설치하기. 나는 스위프트 패키지 매니저 의존성으로 이것을 추가하는 것에서 동작하지 않았는데(빌드 실패), 스위프트3.1이 새로 나오면서 관련된 일시적인 문제가 아닐까 생각이 된다. 결국 가장 최신 배포을 다운받아서 바이너리를 직접 실행시켰다.

2. LinuxMain.stencil이라는 이름의 파일을 생성하는데, 아래 자료를 함께 넣는다. 여러분의 프로젝트에서 편한 곳에 저장한다. 나는 sourcery/의 하위 폴더에 넣었다.
// sourcery:file:Tests/LinuxMain.swift
import XCTest
{{ argument.testimports }}

{% for type in types.classes|based:"XCTestCase" %}
{% if not type.annotations.disableTests %}extension {{ type.name }} {
  static var allTests = [
  {% for method in type.methods %}{% if method.parameters.count == 0 and method.shortName|hasPrefix:"test" %}  ("{{ method.shortName }}", {{ method.shortName }}),  {% endif %}{% endfor %}]}
{% endif %}{% endfor %}

XCTMain([
{% for type in types.classes|based:"XCTestCase" %}{% if not type.annotations.disableTests %}  testCase({{ type.name }}.allTests),
{% endif %}{% endfor %}])
// sourcery:end
이것은 Ilya Puchka가 작성한 양식을 기반으로 하였다. 나는 그냥 처음과 끝에 //sourcery:... 표시를 추가했다. 이것은 생성된 파일의 경로를 결정한다(Sourcery 0.5.9가 필요함).여러분도 볼 수 있듯, 이것은 양식화 언어(templating language)와 스위프트 코드를 합쳐놓았다. Sourcerey를 호출할때, 여러분의 소스코드에서 타입들을 파싱하여 여러분이 보내온 양식에 맞춰 코드를 생성하는데 쓰일 것이다. 예를들어 {% for type in types.classes|based: "XCTesetCase" %}로 시작하는 루프는 XCTestCase를 상속하는 모든 클래스를 돌면서 allTests 프로퍼티를 담은 익스텐션을 생성할 것이다.

3. 여러분의 테스트 클래스에 있는 allTests 정의를 제거하기. 우리는 다음 단계에서 Sourcery로 이것들을 생성할 것이다. 이미 testLinuxTestSuitIncludesAllTests 메소드를 추가했었다면 이것도 제거하거나 여기서부터 떠나게 만들자. 영향을 주진 않고 여전히 이슈를 찾고 있을 것이다, 예를들어, 테스트를 추가하고나서 Sourcery를 실행시키지 않았을때, 더이상 반드시 엄격하게 하진 않을 것이다.

4. 프로젝트 폴더에서 Sourcery를 실행하기.
$ sourcery --sources Tests/ \
   --templates sourcery/LinuxMain.stencil \
   --args testimports='@testable import BananaKitTests'
Scanning sources...
Found 1 types.
Loading templates...
Loaded 1 templates.Generating code...
Finished.
Processing time 0.0301569700241089 seconds
이것은 현재 있던 Tests/LinuxMain.swift 파일을 생성된 코드로 덮어쓸 것이다.
// Generated using Sourcery 0.5.9 — https://github.com/krzysztofzablocki/Sourcery

// DO NOT EDIT

import XCTest@testable
import BananaKitTests

extension BananaTests {
    static var allTests = [
          ("testYellowBananaIsRipe", testYellowBananaIsRipe),
          ("testGreenBananaIsNotRipe", testGreenBananaIsNotRipe),
    ]
}

XCTMain([
     testCase(BananaTests.allTests),
])
이 작은 예제에선 두 테스트와함께 한 클래스만 있지만, 여러 테스트 클래스에도 역시 동작할 것이다.

5. 마지막(선택적인) 단계에는 더이상 생성된 빌드에서 생겨난 파일이 필요없으므로 그것을 제거한다.
$ rm LinuxMain.generated.swift
그리고 이게 다다. 모든 빌드에 실행되는 스크립트를 위해 두가지 터미널 명령(Sourcery를 호출하고 파일을 제거하는)을 추가하고, 여러분의 리눅스 테스트들은 이제 항상 최신으로 유지될것이다. 매우 멋지다!



이 블로그는 공부하고 공유하는 목적으로 운영되고 있습니다. 번역글에대한 피드백은 언제나 환영이며, 좋은글 추천도 함께 받고 있습니다. 피드백은 

으로 보내주시면 됩니다.



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,