본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

Swift Testing, XCTest, 그리고 자체 테스트 실행 파일

~16 min · packages-projects, swift-testing, xctest, selftest, test-design

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"테스트 방식이 셋이어도, 각자 맡은 일이 있으면 난장판이 아니야."

우리 집 규칙: 목적에 맞게 테스트해

가족 앱들은 한때 세 방식을 거의 우연히 섞어 썼어. 지금 규칙은 방식마다 일을 하나씩 맡겨.

  • SwiftPM 패키지 테스트는 Swift Testing. @Test 함수, #expect#require, @Suite 타입, arguments:로 매개변수화한 테스트, .timeLimit(.minutes(1)) 같은 트레이트를 써. swift test로 돌고, 기본이 병렬이야. 새 테스트는 여기에 써.
  • Xcode가 호스팅하는 테스트는 XCTest. iOS나 워치 앱 호스트 안에서 도는 단위 테스트, 확장 테스트, XCUITest로 하는 UI 테스트가 여기 속하고, 전부 XcodeGen 타깃이야. Xcode 호스팅 테스트랑 업로드 파이프라인의 테스트 관문이 이걸 중심으로 짜여 있어.
  • 자체 테스트 실행 파일은 Xcode 없는 Mac에서만. Command Line Tools만 있는 Mac엔 XCTest가 없고, 예전 도구엔 Testing 모듈도 없었어. 6.1 Command Line Tools로 재보니 Swift Testing 묶음은 거기서도 돌아. 가족의 받아쓰기 유틸리티랑 텍스트 변환 유틸리티는 -selftest 실행 파일을 둬. 평범한 검사를 돌리다 실패하면 종료 코드 1로 끝나는 프로그램이야. 그런 Mac에서도 순수 로직을 확인하려는 거고, 레포에도 그 이유가 적혀 있어.

Swift 6.3에서 더해진 것

6.3의 Swift Testing으로는 이런 걸 할 수 있어. 테스트를 실패시키지 않고 보고만 하는 경고 이슈를 남길 수 있어(Issue.record("…", severity: .warning)). 의미 있게 돌 수 없는 테스트는 취소할 수 있고(try Test.cancel()), 결과에 이미지를 붙일 수도 있어. exit 테스트랑 첨부는 6.2에서 들어왔어.

가족이 비싸게 배운 함정들

  • 호스팅된 테스트는 앱 전체를 띄워. iOS 단위 테스트 타깃은 앱 안에서 돌아서 Bundle.main은 맞아. 근데 UserDefaults.standard도 앱의 것이고, 테스트와 함께 뜬 앱은 진짜 엔진 주소로 연결하려고 해. 그러니 기본값 저장소, 알림 센터, 전송 생성은 주입해서 써.
  • AppKit 테스트끼리는 포커스를 공유해. 창이랑 키 뷰를 만드는 테스트는 병렬로 돌면 서로 방해해. 그래서 가족 편집기는 swift test --no-parallel로 돌려.
  • 상관없는 편집에도 증상이 바뀌면 코드 버그가 아니야. 상관없는 단언 두 개짜리 @Suite를 파일 끝에 붙인 적이 있어. 그랬더니 같은 파일에 있던 실제 시계 테스트가 태스크 할당기 실패를 내면서 테스트 프로세스 전체를 죽였어. 여덟 번 돌리면 여덟 번 다. 새 스위트를 별도 파일로 옮기니까 풀렸어. 비쌌던 건 이분 탐색이야. 중간 결과마다 원인처럼 보여서, 조사하다가 멀쩡한 제품 코드를 두 번이나 다시 썼거든. 규칙은 이거야. "고침"이 증상을 바꾸는데 상관없는 편집도 증상을 바꾸면, 이분 탐색을 멈추고 설정마다 반복 횟수를 정해서 하나씩 지워나가.
  • 멈출 수 있는 async 테스트엔 시간 제한이 필요해. .timeLimit 트레이트가 없으면, 영영 재개 안 되는 회귀 하나가 테스트 하나를 실패시키는 대신 실행 전체를 멈춰버려.

Code

패키지용 Swift Testing: 매개변수화, 시간 제한, #require·swift
import Testing
import Foundation
@testable import SparkCore

@Suite("Capture")
struct CaptureTests {
    @Test("blank detection", arguments: [("", true), ("   ", true), ("milk", false), (" 우유 ", false)])
    func blank(text: String, expected: Bool) {
        #expect(Capture(id: "x", text: text).isBlank == expected)
    }

    @Test(.timeLimit(.minutes(1)))
    func roundTripsThroughJSON() throws {
        let capture = Capture(id: "01J", text: "hello")
        let data = try JSONEncoder().encode(capture)
        let decoded = try #require(try? JSONDecoder().decode(Capture.self, from: data))
        #expect(decoded == capture)
    }
}
Command Line Tools만 있는 Mac을 위한 자체 테스트 실행 파일·swift
// Sources/spark-selftest/main.swift — run with: swift run spark-selftest
import SparkCore
import Foundation

var failures = 0

@MainActor func expect(_ condition: Bool, _ message: String, line: Int = #line) {
    if !condition { failures += 1; print("FAIL line \(line): \(message)") }
}

expect(Capture(id: "a", text: "  ").isBlank, "whitespace-only text is blank")
expect(!Capture(id: "b", text: "milk").isBlank, "text with letters is not blank")

print(failures == 0 ? "SPARK_SELFTEST_OK" : "SPARK_SELFTEST_FAILED \(failures)")
exit(failures == 0 ? 0 : 1)

External links

Exercise

두 코드 블록을 SparkKit에 넣어. swift testswift run spark-selftest를 돌려서 출력을 적어. 이어서 Capture.isBlank가 항상 false를 돌려주게 일부러 망가뜨려. Swift Testing 스위트와 자체 테스트가 둘 다 실패하면서 이유를 말하는지 확인하고, 원래대로 되돌려. 마지막으로 절대 재개되지 않는 continuation을 await하는 테스트에 .timeLimit 트레이트를 달아. 실행이 멈추는 대신 정해진 시간 안에 실패하는지 봐.
Hint
한 번도 실패 안 하는 자체 테스트는 아무것도 증명 못 해. 일부러 망가뜨리는 단계가 핵심이야. 멈추는 테스트는 await withCheckedContinuation { _ in } 한 줄이면 돼. 영영 재개되지 않거든. continuation이 샜다고 Swift가 경고할 수도 있는데, 이 실험에선 괜찮아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.