SwiftTesting 진짜 맛만보기

·2026년 3월 4일

iOS-posting

목록 보기
11/14

채용 공고들의 JD(Job Description)을 보면 꽤나 자주 등장하는 멘트가 있다.

바로 테스트 코드에 관련된 것인데, 이거는 아마 포지션 불문하고 자주 등장하는 것을 보았을 것이다.

아무리 Ai시대라고해도, 그냥 테스트코드해줘! 라고하면 Ai도 당황하게마련이다. (Ai입장에서는 얘가 테스트라는게 뭔지는 알고 이렇게 프롬프팅을 주는건가?하는...)

그래서 이번 포스팅에서는 Xcode를 통한 UnitTest를 하는 방법을 포스팅해보려한다.
XCTest대신, 최신 라이브러리인 SwiftTesting으로 진행합니다!

SwiftTesting 테스트 세팅

  1. File -> New -> Target

2.Unit Testing Bundle 선택

  1. 적당한 이름 짓고 생성 ( 앱 이름 + Tests로 하는게 국룰이라고 한다.)

4.생성된 Test 폴더 확인


Tests.swift

코드를 보면 이런식으로 되어있는데, 차근차근 알아보자!

import Testing

struct PhotoCleanerTests {

    @Test func example() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }

}

XCTest에서 SwiftTesting으로 넘어오면서 여러가지 바뀐 것중에, XCTestCase클래스기반에서 struct기반으로 변경된 것이 크다고 볼 수 있다.

@Test 테스트 어노테이션을 통해서 함수앞에 붙여주면 해당함수는 유닛테스트가 필요한 함수라고 알려주는 것이다.

간단 테스트

import Testing

struct PhotoCleanerTests {
    @Test func addition() {
        let result = 1 + 1
        #expect(result == 3)
    }
}

1+1이 3이라고 테스트를 해보자!

expect에 넣은 1+1 == 3은 테스트 결과랑 다르다고 경고를 주는 것이다.

1+1 이 2라고 정정을 하면?

이렇게 된다. 이제 실제 함수를 가져와서해보자!!

@Suite — 테스트 묶기

테스트가 많아지다 보면 하나의 파일에 @Test 함수들이 난잡하게 쌓이기 시작한다.

그래서 등장하는게 @Suite다.

@Suite("DecisionManager")
struct DecisionManagerTests {
    @Test("결정 기록 - kept 저장")
    func recordDecision_savesKeptDecision() { ... }

    @Test("undo - 마지막 결정 복구")
    func undoLastDecision_restoresUndecided() { ... }
}

XCTest에서 XCTestCase를 클래스로 상속해서 테스트를 묶던 것과 같은 역할인데, SwiftTesting에서는 그냥 struct@Suite만 붙이면 된다.

Xcode 왼쪽 네비게이터에서 이렇게 깔끔하게 구분해서 볼수가 있는 것이다.

결국에는 다 편하게 할 수 있게 구성된거라 있는거 잘 이용하면 될 것 같다...!


그러면 실제 앱 코드를 테스트해보자

1+1 같은 예시 말고, 내가 만든 앱에서 DecisionManager라는 클래스를 테스트해보기로 했다.

DecisionManager는 사진을 킵(왼쪽 스와이프), 삭제(오른쪽 스와이프) 결정을 기록하고, 그러면서도 이게 되돌리기(Undo버튼)을 지원하고 있는 클래스인데, 이렇게 실제 클래스 주입이 필요한 것에 대해서는 어떻게 해결해줄 수 있을까?

  • 정답은 실제로 안에 실제 ViewModel만들듯이 만들어주면 된다.
@Suite("DecisionManager", .serialized)
@MainActor
struct DecisionManagerTests {

    init() {
        UserDefaults.standard.removeObject(forKey: "photo_decisions")
        UserDefaults.standard.removeObject(forKey: "photo_decision_metadata")
    }

    @Test("결정 기록 - kept 저장")
    func recordDecision_savesKeptDecision() {
        let manager = DecisionManager()
        manager.recordDecision(for: "test-asset-id", decision: .kept)
        #expect(manager.getDecision(for: "test-asset-id") == .kept)
    }

    @Test("undo - 마지막 결정 undecided로 복구")
    func undoLastDecision_restoresUndecided() {
        let manager = DecisionManager()
        manager.recordDecision(for: "id-1", decision: .trashed)
        _ = manager.undoLastDecision()
        #expect(manager.getDecision(for: "id-1") == .undecided)
    }
}

근데 간혹가다가 viewModel이나, Service나, 여타 이런 클래스들이 너무 볼륨이 크거나, 테스트에는 필요하지 않은 친구들이 있다면?

  • 이런친구들을 위해서는 결국 해당 Service레이어와 같은 프로토콜을 공유하는 MockSerivice를 만들어서 해결해 줄 수 있다.

특히 HomeViewModel에는 카드를 넘길 때마다 다음 사진들을 미리 캐싱해두는 prefetchImages 로직이 있는데,

// HomeViewModel.swift
final class HomeViewModel {
    private let photoService: PhotoLibraryService  

    init(photoService: PhotoLibraryService, ...) { ... }
}

그래서 이걸 PhotoLibraryService에서 프로토콜을 뽑아내서 Mock과 함께 공유할 것이다.

// PhotoLibraryServiceProtocol.swift
protocol PhotoLibraryServiceProtocol: AnyObject {
    var assets: [PHAsset] { get }
    func fetchPhotos(from album: PhotoAlbum?) async
    func prefetchImages(for assets: [PHAsset], targetSize: CGSize)
    func stopPrefetchingImages(for assets: [PHAsset], targetSize: CGSize)
    // ...
}

// 기존 클래스는 참조만 추가
final class PhotoLibraryService: PhotoLibraryServiceProtocol { ... }

Mock만들기

// MockPhotoLibraryService.swift (테스트 타겟에만 존재)
@Observable @MainActor
final class MockPhotoLibraryService: PhotoLibraryServiceProtocol {
    var assets: [PHAsset] = []
    var stubbedPhotos: [PHAsset] = []

    // 호출 추적
    var prefetchCalledAssets: [[PHAsset]] = []
    var fetchPhotosCalled = false

    func fetchPhotos(from album: PhotoAlbum?) async {
        fetchPhotosCalled = true
        assets = stubbedPhotos
    }

    func prefetchImages(for assets: [PHAsset], targetSize: CGSize) {
        prefetchCalledAssets.append(assets)  // 호출됐는지 기록
    }
    // ...
}

이런식으로 테스트를 해주면 되는 것이다.

@Suite("HomeViewModel - Prefetch")
@MainActor
struct HomeViewModelPrefetchTests {

    @Test("빈 라이브러리 - prefetch 호출 없음")
    func loadPhotos_emptyLibrary_noPrefetch() async {
        let mockService = MockPhotoLibraryService()
        mockService.stubbedPhotos = []  // 사진 없는 상황 세팅

        let vm = HomeViewModel(photoService: mockService, decisionManager: DecisionManager())
        await vm.loadPhotos()

        #expect(mockService.fetchPhotosCalled == true)   // fetchPhotos는 호출됐고
        #expect(mockService.prefetchCalledAssets.isEmpty) // prefetch는 호출 안 됨
    }
}

MockPhotoLibraryService를 주입하면 실제 사진 라이브러리 없이도 HomeViewModel의 동작을 검증할 수 있다.

처음 접해본 Swift Testing은 예상보다 훨씬 간결한 것 같네요..!

그냥 적재적소에 #expect()를 배치하는 것만으로도 충분할 만큼 테스트의 문턱이 낮아진 느낌을 받았습니다.

물론 아직 사용한게 얼마만치않아서 더 넓은 세상이 있겠지만, 인생 첫 테스트를 돌려봤다는 것에 의의를 두려고 합니다..!
물론이게 단순히 테스트가 성공했으니 끝! 이면 사실 아까 처음썼던 1+1 정도만 달면되겠죠..!
이게 새로운 기능을 추가할 때, 혹은 아리까리한 부분이 있을때 일일히 다 실기기로 테스트하기에는 무리가 있으니 간단한 것이라도 하나씩 써보면서 익혀나가야할 것 같습니당.

자료 출처 :
https://developer.apple.com/documentation/testing
https://developer.apple.com/videos/play/wwdc2024/10179/

profile
기억보단 기록을

0개의 댓글