XCTest 케이스가 개별적으로 실행될 때와 테스트 모음으로 실행될 때 모두 통과하도록 하려면, 각 테스트에 자체 변경 가능한 픽스처를 제공하고 테스트 대상 코드에 주입하세요. 싱글턴을 공유 상태로 유지해야 한다면 모든 테스트 전에 초기화하고, 테스트가 진행되는 동안 겹치는 접근을 방지하세요.

이 가이드는 하나의 Mac 개발 작업을 다룹니다. Xcode에서 단독으로 실행하면 통과하지만 함께 실행하면 실패하는 레이아웃 계산 테스트를 수정하는 작업입니다. 예제는 합성 정수 너비와 로컬 산술만 사용합니다. 앱 윈도우, 서비스 모의 객체, 파일 저장소 또는 네트워크 연결은 필요하지 않습니다.

테스트가 단독으로는 통과하고 테스트 모음에서는 실패하는 이유는 무엇인가요?

테스트를 격리해 실행하면 싱글턴의 초기 상태를 만날 수 있습니다. 테스트 모음에서는 이전 테스트가 이미 상태를 변경했을 수 있습니다. 싱글턴에 대한 새 참조도 여전히 동일한 변경 가능한 객체를 가리킵니다.

Swift는 인스턴스 프로퍼티와 타입 프로퍼티를 구분합니다. 각 인스턴스에는 자체 인스턴스 프로퍼티가 있지만, 타입 프로퍼티는 하나의 복사본을 공유합니다. 저장 타입 프로퍼티는 처음 접근할 때 한 번 초기화됩니다. 따라서 SpacingCalculator.shared를 테스트 프로퍼티에 할당해도 새 계산기가 만들어지지 않습니다. Swift 프로젝트의 프로퍼티 문서를 참조하세요.

너비가 [10, 20, 30]인 항목들로 구성된 합성 행을 생각해 보세요. 전체 너비는 항목 너비의 합에 항목 사이 간격을 더한 값입니다. 기본 간격이 2이면 결과는 64이고, 간격이 8이면 결과는 76입니다.

한 테스트는 계산기를 설정하지 않고 기본 너비를 검사하고, 다른 테스트는 공유 간격을 8으로 변경한다고 가정해 보겠습니다. 다음 표는 각 행을 간격이 2인 새 계산기로 시작하는 순차 실행을 설명합니다. 이는 예시 계산이며, 기록된 Xcode 테스트 결과가 아닙니다.

실행 초기화하지 않은 공유 계산기 테스트마다 새 계산기
기본 테스트 단독 실행 64: 통과 64: 통과
넓은 간격 테스트 단독 실행 76: 통과 76: 통과
기본 테스트 후 넓은 간격 테스트 둘 다 통과 둘 다 통과
넓은 간격 테스트 후 기본 테스트 기본 테스트가 76을 받음: 실패 둘 다 통과

이 결과는 유용한 진단 기준이 됩니다. 실패한 어설션이 사용하는 상태를 어떤 테스트가 변경하는지 살펴보세요. 유리한 순서를 얻도록 테스트 이름을 바꾸면 의존성은 그대로 남습니다.

어떤 XCTest 수명 주기 메서드가 픽스처를 관리해야 하나요?

모든 테스트에 필요한 변경 가능한 상태에는 인스턴스 설정 메서드를 사용하세요. Apple 문서에 따르면 인스턴스 설정은 각 테스트 메서드 전에 실행되고, 클래스 수준 설정은 테스트 클래스 전에 한 번 실행됩니다. 따라서 클래스 수준 설정에서 만든 변경 가능한 픽스처는 메서드마다 새로운 기준 상태를 갖지 않습니다. 테스트 상태 설정 및 해제를 참조하세요.

작업에 맞는 설정 메서드를 선택하세요. 동기식이며 오류를 던지지 않는 픽스처 생성에는 setUp()가 적합하고, 오류를 던지는 생성에는 setUpWithError(), 비동기 준비에는 setUp() async throws이 적합합니다. 현재 테스트의 정리는 해제 메서드에서 완료하세요. Apple은 충돌로 인해 해제 메서드가 실행되지 않을 수 있으므로, 다음 테스트를 준비하는 데 해제 메서드에 의존하지 말라고 명시적으로 권고합니다. 같은 수명 주기 문서에서 이 경계를 설명합니다.

픽스처 선택은 소유권과 실행 범위 모두에 따라 달라집니다.

상황 적절한 접근 방식 확인할 조건
테스트에서 종속성을 생성할 수 있음 테스트마다 인스턴스를 만들고 주입 소비자가 주입된 해당 인스턴스를 사용해야 함
실제 싱글턴을 테스트해야 함 각 테스트 전에 완전한 기준 상태 설정 겹치는 테스트가 해당 싱글턴을 변경하지 않아야 함
공유 설정이 변경 불가능함 변경 불가능한 값을 재사용 참조된 객체도 변경되지 않아야 함
테스트가 비동기 작업을 시작함 테스트를 끝내기 전에 완료를 기다림 이후 픽스처를 변경할 미완료 작업이 없어야 함

이 계산기에는 첫 번째 방법이 가장 간단합니다. 프로덕션 코드는 공유 기본값을 유지하면서 테스트에서는 명시적으로 독립 인스턴스를 선택할 수 있습니다.

독립적으로 소유되는 XCTest 픽스처 만들기

Xcode가 설치된 Mac과 기존 macOS 단위 테스트 타깃을 사용하세요. 다음 전체 예제를 테스트 타깃의 Swift 파일 하나에 추가합니다. 자체 계산기, 소비자, 테스트를 정의하므로 애플리케이션 모듈을 가져올 필요가 없습니다. 타깃의 기본 액터 격리가 nonisolated으로 설정된 Swift 6 툴체인을 사용하세요.

계산기는 변경 가능한 상태의 동시성 경계를 명확히 표현하기 위해 액터입니다. Swift 액터는 변경 가능한 상태에 대한 접근을 제한하고, 액터 외부에서 호출할 때는 await을 사용합니다. 이 규칙은 Swift 프로젝트의 동시성 장에 설명되어 있습니다. 액터를 사용하더라도 픽스처를 독립적으로 소유해야 합니다.

import XCTest

actor SpacingCalculator {
    static let shared = SpacingCalculator()

    private var gap = 2

    func setGap(_ value: Int) {
        precondition(value >= 0)
        gap = value
    }

    func width(of itemWidths: [Int]) -> Int {
        precondition(itemWidths.allSatisfy { $0 >= 0 })
        let gapCount = max(itemWidths.count - 1, 0)
        return itemWidths.reduce(0, +) + gapCount * gap
    }
}

struct RowLayout {
    let calculator: SpacingCalculator

    init(calculator: SpacingCalculator = .shared) {
        self.calculator = calculator
    }

    func width(of itemWidths: [Int]) async -> Int {
        await calculator.width(of: itemWidths)
    }
}

final class RowLayoutTests: XCTestCase {
    private var calculator: SpacingCalculator?

    override func setUp() {
        super.setUp()
        calculator = SpacingCalculator()
    }

    override func tearDown() {
        calculator = nil
        super.tearDown()
    }

    func testDefaultGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 64)
    }

    func testWideGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        await fixture.setGap(8)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 76)
    }

    func testEmptyRow() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [])

        XCTAssertEqual(actual, 0)
    }

    func testSingleItem() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10])

        XCTAssertEqual(actual, 10)
    }
}

핵심이 되는 줄은 calculator = SpacingCalculator()입니다. 각 설정은 간격이 2인 독립 액터를 생성합니다. 그런 다음 각 테스트는 해당 액터를 RowLayout에 전달합니다. 이 테스트 안에서 RowLayout()를 호출하면 .shared가 선택되어 픽스처를 우회하게 됩니다.

이 예제는 의도적으로 직접 생성을 허용합니다. 기존 싱글턴의 이니셜라이저가 비공개라면 내부 생성 경로를 추가하거나, 변경 가능한 계산 상태를 별도로 생성할 수 있는 종속성으로 추출하세요. 애플리케이션 모듈에서 이미 사용하는 테스트 가능한 가져오기 구성으로 테스트 타깃에 내부 이니셜라이저를 노출할 수 있으므로, 싱글턴 참조를 전역에서 교체 가능하게 만들 필요는 없습니다.

산술 예제에는 유용한 경계 사례도 두 가지 있습니다. 빈 행의 너비는 0이고, 항목이 하나뿐이면 사이에 간격이 없습니다. 이 예제는 정수 오버플로가 발생하지 않을 만큼 작은 음이 아닌 정수를 허용합니다. 음수 값은 사전 조건을 위반하므로 어설션 실패 대신 실행이 종료됩니다.

예상 산술과 두 가지 2개 테스트 실행 순서를 로컬에서 결정론적 Python 계산으로 확인했습니다. 이 연구 환경에는 Swift 툴체인이 없어 Swift 예제는 검토했지만 컴파일하거나 실행하지 않았습니다. 타깃에서 컴파일 및 실행 여부를 확인하려면 Xcode에서 실행하세요.

싱글턴 초기화만으로 충분한 경우는 언제인가요?

테스트에서 공유 인스턴스를 실제로 사용해야 하고 테스트에 노출된 전체 상태를 복원할 수 있다면, 초기화는 과도기적인 방법이 될 수 있습니다. 기본값으로 가정하는 값까지 포함해 모든 테스트 전에 기준 상태를 설정하세요. 현재 실패한 테스트가 변경한 프로퍼티만 초기화하면 다른 종속성이 드러나지 않은 채 남을 수 있습니다.

이 계산기의 경우 비동기 설정에서 SpacingCalculator.shared를 선택하고 각 메서드 전에 setGap(2)을 기다릴 수 있습니다. 어설션 전에 겹치는 호출자가 간격을 변경하지 않는 경우에만 산술의 기준 상태가 보장됩니다. 이후 픽스처에 캐시된 결과나 추가 설정이 생기면 기준 상태 설정 작업에서 그 값도 모두 다뤄야 합니다.

액터를 사용해도 여러 번의 await 호출 동안 한 테스트가 소유권을 독점하는 것은 아닙니다. 예를 들어 테스트 A가 공유 간격을 2로 설정하고, 테스트 B가 공유 간격을 8로 설정한 뒤, 테스트 A가 8을 사용해 너비를 계산할 수 있습니다. 각 액터 작업은 올바르게 실행되어도 전체 테스트의 가정은 실패할 수 있습니다. 이는 Swift의 문서화된 액터 실행 및 일시 중단 규칙에서 비롯됩니다.

초기화 기반 격리를 선택하기 전에 공유 객체에 접근할 수 있는 모든 테스트와 백그라운드 작업을 파악하세요. 준비, 실행, 정리 전반에 걸쳐 독점 사용을 보장할 수 없다면 독립 상태를 주입하세요. 이후 스냅샷을 복원해도 테스트 중 다른 호출자가 일시적인 변경 상태를 관찰하는 것을 막을 수는 없습니다.

이 픽스처를 Swift Testing에 적용하려면 어떻게 하나요?

Swift Testing은 인스턴스 테스트 함수마다 별도의 스위트 인스턴스를 초기화합니다. 따라서 계산기를 생성하는 인스턴스 프로퍼티를 사용하면 XCTest 설정 오버라이드 없이도 같은 소유권 패턴을 구현할 수 있습니다. .shared에서 초기화한 프로퍼티는 여전히 싱글턴을 참조합니다. Apple은 스위트 타입으로 테스트 함수 구성하기에서 인스턴스 수명 주기를 설명합니다.

앞서 정의한 SpacingCalculator와 RowLayout이 같은 테스트 타깃에 한 번 정의되어 있다면, 다음은 완전한 추가 Swift Testing 스위트입니다. Xcode 16 이상과 같이 Testing 모듈이 포함된 툴체인이 필요합니다. 기본 액터 격리도 nonisolated이어야 합니다.

import Testing

struct RowLayoutSwiftTests {
    let calculator = SpacingCalculator()

    @Test func defaultGap() async {
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 64)
    }

    @Test func wideGap() async {
        await calculator.setGap(8)
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 76)
    }
}

더 복잡한 준비를 위해 Swift Testing은 비동기 또는 오류를 던지는 초기자를 포함한 스위트 초기자를 지원합니다. Apple의 XCTest 테스트 마이그레이션에서는 XCTest 설정 메서드를 대체하는 방법을 설명합니다. 이 픽스처는 로컬 계산 상태만 소유하고 백그라운드 작업을 시작하지 않으므로 명시적인 정리가 필요하지 않습니다.

Swift Testing은 기본적으로 테스트를 병렬 실행합니다. 스위트에 .serialized를 적용하면 그 안의 테스트와 중첩 스위트가 순차적으로 실행되지만, 관련 없는 테스트가 동시에 실행되는 것은 막지 않습니다. 일반적인 매개변수 없는 테스트 함수에 이 특성을 적용해도 효과가 없습니다. 이러한 제한은 테스트를 순차 또는 병렬로 실행하기에 명시되어 있습니다.

따라서 싱글턴에 의존하는 두 스위트를 각각 별도의 .serialized 주석 아래에 배치해도 둘 사이의 상호 배제가 보장되지 않습니다. 경쟁하는 모든 사용자가 해당 범위에 속한다면 공통 직렬화 상위 스위트가 하위 스위트들을 조정할 수 있습니다. 이 계산 테스트에서는 새 픽스처를 사용해 이러한 조정이 필요하지 않도록 할 수 있습니다.

Xcode에서 개별 테스트 및 테스트 모음 결과 확인하기

검증하는 동안 동일한 스킴, 테스트 계획, Mac 대상을 사용하세요. Apple은 테스트 실행 및 결과 해석에서 개별 메서드, 스위트, 활성 테스트 계획을 실행하는 방법을 설명합니다.

  1. 편집기 여백의 재생 컨트롤을 사용해 네 XCTest 메서드를 각각 실행합니다. 차례대로 64, 76, 0, 10가 예상됩니다.
  2. 선언 옆의 재생 컨트롤을 사용해 RowLayoutTests 클래스를 실행합니다. 네 메서드가 모두 함께 통과해야 합니다.
  3. Product > Test를 선택해 활성 테스트 계획을 실행합니다. 이 테스트들이 건너뛰거나 제외되지 않고 실제로 실행되었는지 확인합니다.
  4. Swift Testing 예제를 추가했다면 해당 함수 두 개도 각각 실행하고 함께 실행합니다.

개별 테스트는 통과하지만 전체 계획이 실패한다면 실패한 소비자가 사용하는 객체를 추적하세요. 주입 누락, .shared를 호출하는 헬퍼, 또는 시작한 테스트가 끝난 뒤에도 계속 실행되는 비동기 작업을 살펴보세요. 테스트가 단독으로 실패한다면 테스트 순서 탓으로 돌리기 전에 입력값과 예상 계산을 확인하세요.

완성된 픽스처는 현재 테스트의 생성 경로에서 시작 상태를 명확히 보여줘야 합니다. 이 레이아웃 계산에서는 각 테스트가 계산기를 소유하고 소비자에게 명시적으로 전달한 다음 작업이 끝나기를 기다립니다. 따라서 예상 결과는 이웃한 테스트 중 무엇이 먼저 실행되었는지에 영향을 받지 않습니다.

출처