Unit Test
개요
이 글에서는 테스트 종류와 사용되는 객체에 대해 정리하고,
기존 테스트 방식인 XCTest와 WWDC24에 발표한 Swift Testing으로 유닛테스트를 수행하는 방법에 대해 정리한다
그리고 그 둘을 비교하며 글을 마무리한다
테스트 종류와 대체 객체
여러 테스트들과 이때 사용되는 대체 객체들을 정리하면 다음과 같다
Testing
- Unit Testing - 각 소프트웨어 컴포넌트의 동작을 테스트
- UI Testing - UI가 예상대로 동작하는지 테스트(https://developer.apple.com/documentation/xctest/user_interface_tests)
- Compatibility Testing - 다른 기기에서 사용같은 다양한 상황에서 올바르게 동작하는지 테스트
- Regression Testing - 새 기능이 기존 기능에 문제를 일으키지 않는지 테스트(https://developer.apple.com/documentation/xctest/performance_tests)
Test Double
테스트에서 사용되는 대체 객체다
Dummy
- 인터페이스 유지를 위해 사용
Stub
- 고정된 결과나 동작을 제공
Fake
- Fake는 원래 클래스의 동작을 훨씬 더 간단한 방식으로 복제하는 구현을 가진 객체
- 테스트 중인 코드가 다른 객체에 의존하는 경우 해당 객체의 동작을 모방하는 Fake 객체로 대체하여 테스트 수행
Mock
- Mock은 Fake과 비슷하지만 함수가 호출된 횟수와 호출된 인수를 추적
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, #requre, Parmeterized 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를 사용하지는 않아도 괜찮을 것 같다)