iOS+

Unit Test

hot6 2024. 6. 22. 17:36

개요

이 글에서는 테스트 종류와 사용되는 객체에 대해 정리하고,

기존 테스트 방식인 XCTest와 WWDC24에 발표한 Swift Testing으로 유닛테스트를 수행하는 방법에 대해 정리한다

그리고 그 둘을 비교하며 글을 마무리한다

 

테스트 종류와 대체 객체

여러 테스트들과 이때 사용되는 대체 객체들을 정리하면 다음과 같다

Testing

Test Double

테스트에서 사용되는 대체 객체다

Dummy

  • 인터페이스 유지를 위해 사용

Stub

  • 고정된 결과나 동작을 제공

Fake

  • Fake는 원래 클래스의 동작을 훨씬 더 간단한 방식으로 복제하는 구현을 가진 객체
  • 테스트 중인 코드가 다른 객체에 의존하는 경우 해당 객체의 동작을 모방하는 Fake 객체로 대체하여 테스트 수행

Mock

  • MockFake과 비슷하지만 함수가 호출된 횟수와 호출된 인수를 추적

Spy

  • 다른 객체와의 인터렉션을 기록

 

유닛테스트 코드 작성 시 주의사항

패키지나 프로젝트를 만들 때, 테스트 코드를 작성하려면 이 원래 코드를 불러올 수 있게하는 설정 작업이 필요하다

만약 테스트에서 다른 스위프트 패키지를 이용한다면, Build Phases에서 Link Binary With Libraries 설정해야 한다

그리고 작성한 코드를 테스트 코드에서 사용할 수 있는 방법은 다음과 같이 두 가지가 있다

Target Membership 설정

우측 패널에서 TargetMembership으로 추가한다

@testable import

@testable로 테스트하려는 요소를 import 한다

이렇게 하면 해당 모듈의 internal 접근 수준 이하의 코드를 테스트에서 사용할 수 있다

이 방법은 테스트 대상 코드가 앱 타겟에만 속해 있고, 테스트 타겟에는 속하지 않는 경우에 유용하다

또는 그냥 프로젝트명을 쓰면 전부 사용 가능하다

 

XCTest

XCTest는 애플이 제공하는 테스팅 프레임워크로 애플 플렛폼 애플리케이션을 위한 단위 테스트, 성능 테스트, UI 테스트를 작성하고 실행할 수 있다(XCTest는 Xcode와 완벽하게 통합되어 있음)

XCTest의 주요 구성요소와 그 예시는 다음과 같다

XCTest의 주요 구성요소

XCTestCase

XCTest의 핵심은 XCTestCase를 상속받는 클래스를 정의하는 것이다

이 클래스 내부에 테스트 메서드를 작성하며, 각 테스트 메서드는 특정 기능이나 동작을 검증한다

테스트 메서드 내부에서는 XCTAssert 계열의 함수를 사용하여 특정 조건이 참인지 확인한다

setUp()과 tearDown()

XCTestCase 클래스는 setUp()과 tearDown() 두 가지 메서드를 제공한다

setUp() 메서드는 각 테스트 메서드가 실행되기 전에 호출되며, 테스트에 필요한 초기 설정을 수행한다

반대로 tearDown() 메서드는 각 테스트 메서드가 실행된 후에 호출되며, 테스트에 사용된 자원을 정리하는 등의 마무리 작업을 수행한다

Naming Convention

XCTest에서는 테스트 메서드의 이름이 test로 시작해야 한다

이 규칙을 따르면 XCTest는 자동으로 해당 메서드를 테스트 메서드로 인식하고 테스트 실행 시에 호출한다

XCTest 예시

import XCTest
@testable import MyProject

class MyProjectTests: XCTestCase {

    var calculator: Calculator  /// MyProject에 존재

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

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

    func testAddition() {
        /// Given
        let number1 = 1
        let number2 = 2
        let expectedSum = 3

        /// When
        let result = calculator.add(number1, number2)

        /// Then
        XCTAssertEqual(result, expectedSum, "Expected \\(expectedSum), but got \\(result)")
    }
}

 

Swift Testing

The testing library has experimental integration with Swift Package Manager’s swift test command and can be used to write and run tests alongside, or in place of, tests written using XCTest.

 

 

WWDC24에서 새로운 테스트관련 오픈소스를 라이브러리인 Swift Testing을 공개했다

스위프트 표현식을 사용하여 작성할 수 있어서 기존 XCTest를 사용할 때보다 더 쉽고

관련 테스트 묶음 및 병렬 처리와 테스트 코드를 작성하는 방식이 간단해진 느낌이다

테스트 수행테스트 관리의 관점에서 정리해보면 다음과 같다

 

테스트 수행

@testable로 모듈을 import하고 테스트 코드 작성을 할 때, Swift Testing은 @Test를 사용하여 테스트 코드를 작성한다

@Test는 다음과 같은 요소들에 적용이 가능하다

 

  • func or method in a type
  • async, throws
  • actor-isolated

이때, XCTest와 달리 딱히 Naming Convention이 필요 없고, 다음과 같이 어떤 테스트인지에 대해 문자열을 전달할 수도 있다

@Test(”테스트입니다”)
func testExample() { ...

 

@Test를 이용하여 테스트 코드를 작성하는데 다음과 같은 요소(#expect#requreParmeterized Testing)들이 이용된다

 

#expect

스위프트로 작성된 표현식을 그대로 검증하는데 사용된다

이때 실패지점과 관련된 부분을 콜스택으로 자세히 관찰도 가능하다

@Test
func testExample() {
    let result = someFunction()
    #expect(result == true, "Expected true, but got \\(result)")
}

#requre

try를 사용하는 구문 테스트에 사용된다

@Test
func testExample() throws {
    let result = try #require(someThrowingFunction())
    #expect(result == true)
}

Parmeterized Testing

@Test에 사용되는 인자를 arguments에 넘기는데 사용된다(이때 Iterable을 준수해야한다)

각 요소에 대한 디테일 확인가능하고, 병렬로 처리하며 디버깅 시 특정요소 재실행 가능하게 해준다

@Test(arguments: [
		"A Beach",
		"By the Lake",
		"Camping in the Woods",
])
func mentionedContinentCounts(videoName: String) async throws {
		let videoLibrary = try await VideoLibrary)
		
		let video = try #require(await videoLibrary.video(named: videoName))
		#expect(!video.mentionedContinents.isEmpty)
		#expect(video.mentionedContinents.count <= 3)
}

#confirmation

주로 비동기함수에서 콜백함수가 호출되었는지 확인하는 데 사용된다

@Test
func testFetchData() async {
    let api = AsyncAPI()
    
    let confirmation = #confirmation(expectedCount: 1)
    api.fetchData { result in
        switch result {
        case .success:
            confirmation.fulfill()  /// count += 1
        case .failure:
            break
        }
    }
    try await confirmation.expectFulfillment()
}

다음과 같이 #confirmation을 생성하여 .fulfill()을 동작 완료 시 호출시키고,

마지막에 .expectFulfillment()으로 호출 여부(횟수) 확인한다

 

테스트 관리

위에서 테스트 코드를 작성하는 데 사용되는 요소들에 대해 언급했다면

다음은 테스트 코드들을 관리하는 데 사용되는 요소들이다

@Suite

@Suite는 여러 테스트를 그룹화시켜 주는데, 이를 통해 테스트를 구조화하고 관리하기 쉽게 만든다

이때 init과 deinit은 테스트스위트에서 여러 테스트 케이스를 위한 준비와 정리 작업에 사용된다

@Suite
struct DatabaseTests {
    var database: Database?  /// MyProject에 존재하는 Database 클래스

		/// 테스트 전 수행
    init() {
        database = Database()
        database?.connect()
    }
		
		/// 테스트 후 수행
    deinit {
        database?.disconnect()
        database = nil
    }

    @Test
    func testInsertion() {
        // Given
        let record = Record(id: 1, data: "Test data")

        // When
        let result = database?.insert(record)

        // Then
        #expect(result == true)
    }

    @Test
    func testDeletion() {
        // Given
        let recordID = 1

        // When
        let result = database?.delete(recordID)

        // Then
        #expect(result == true)
    }
}

@Tag

@Tag는 테스트에 문자열 레이블을 부여하여, 테스트를 분류하고 필터링하는 데 사용된다

@Tag("Network")
@Test
func testExample() {
    let result = someNetworkFunction()
    #expect(result == true)
}

또는 다음과 같이 Tag를 확장하여 사용할 수도 있다

extension Tag {
		@Tag static var caffeinated: Self
}

@Suite(.tags.caffeinated)) struct DrinkTests {
		@Test func espressoExtractionTime() { /* ... */ }
		@Test func greenTeaBrewTime () { /*... */ } 
		@Test func mochaIngredientProportion) { /* ... */ }
}

@Suite struct DessertTests {
		@Test(.tags(.caffeinated)) func espressoBrownieTexture() { /* ... */ }
		@Test func fruitMochiFlavors() { /* ... */ }
}

@Trait

@Trait는 테스트에 특정 특성을 부여하는 데 사용되는데, 이때 TestTag 프로토콜을 준수하는 구조체로 정의한다

enum NetworkStatus {
    case online
    case offline
}

class NetworkManager {
    var status: NetworkStatus

    init(status: NetworkStatus) {
        self.status = status
    }

    func fetchData() -> Result<Data, Error> {
        switch status {
        case .online:
            return .success(Data())
        case .offline:
            return .failure(NSError(domain: "", code: -1, userInfo: nil))
        }
    }
}

 

예를들어 다음과 같이 NetworkManager가 NetworkStatus에 따라 Data나 Error를 반환하는데

테스트코드를 작성해야 한다면, 다음과 같이 @Trait을 만들어서 인자로 사용하면 된다

@Trait
struct NetworkConnected: TestTag {
    static let description = "Network Connected"
    let status: NetworkStatus
}

@Test
@NetworkConnected(status: .online)
func testFetchDataWithNetwork(networkStatus: NetworkConnected) {
    let networkManager = NetworkManager(status: networkStatus.status)
    let result = networkManager.fetchData()
    switch result {
    case .success:
        #expect(true)
    case .failure:
        #expect(false)
    }
}

@Test
@NetworkConnected(status: .offline)
func testFetchDataWithoutNetwork(networkStatus: NetworkConnected) {
    let networkManager = NetworkManager(status: networkStatus.status)
    let result = networkManager.fetchData()
    switch result {
    case .success:
        #expect(false)
    case .failure:
        #expect(true)
    }
}

 

XCTest와 Swift Testing 비교

다음은 XCTest와 비교한 Swift Testing 사용의 장점들이다

검증식 간략화

XCTest는 검증함수(XCTAssert 계열)를 다양하게 제공해 준다

Swift Testing은 다양한 함수를 쓰기보단 직접 표현식을 작성해서 검증하면 되기에 편리하다

테스트 함수명 간략화 및 병렬 수행

Swift Testing을 이용하면 @Test로 테스트할 함수를 지정가능하고 설명을 추가할 수 있기 때문에, XCTest에 비해 함수명을 간략화할 수 있다

그리고 각 @Test들은 병렬로 테스트가 수행된다

다양한 타입 지원과 테스트 관리

XCTest는 XCTestCase의 서브클래스를 정의하여 테스트 코드를 작성해야 테스트들을 관리할 수 있지만

Swift Testing을 사용하면 그냥 스위프트 타입에서 @Test를 붙이기만 하면 된다

그리고 각 테스트 전에 수행할 내용들은 간단하게 init과 deinit으로 정의하면, @Suite으로 묶은 내용 안에서 테스트마다 동작하게 된다

 

마무리

WWDC24 영상들을 봤는데, Swift Testing은 유용하게 사용할 것 같아서 빠르게 정리해보았다

Swift Testing은 Swift에 포함되어 패키지에서 swift test 명령어를 통해 테스트를 수행시킬 수도 있다

개인적으로 애플이 오픈소스 프로젝트들을 늘리고, 다른 플랫폼으로 스위프트 생태계를 확장하고 있는 느낌을 느꼈는데

Swift Testing 또한 크로스플랫폼 지원을 한다(패키지를 작성한다면 꼭 XCode를 사용하지는 않아도 괜찮을 것 같다)

참고