بنقرة واحدة
ios-viewmodel-test-generator
// iOS ViewModel 유저 플로우 기반 테스트 코드 자동 생성. Given-When-Then 패턴으로 Input/Output 검증 테스트를 생성합니다. XCTest와 Combine을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
// iOS ViewModel 유저 플로우 기반 테스트 코드 자동 생성. Given-When-Then 패턴으로 Input/Output 검증 테스트를 생성합니다. XCTest와 Combine을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
테크스펙 문서를 기반으로 새로운 피처를 구현하는 전체 파이프라인을 자동화합니다. 구현 → 테스트 → 수정 → 커밋까지 자동으로 수행합니다.
iOS Repository 패턴 정의. Protocol + Impl 구조 및 API enum + RequestType 확장 패턴으로 네트워크 레이어를 구현합니다. async/await와 Result 타입을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
iOS MVVM 패턴의 ViewModel 구조 정의. Input/Output/Route/Config/Dependency/State 패턴으로 ViewModel을 생성합니다. Combine 기반으로 작동하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
| name | ios-viewmodel-test-generator |
| description | iOS ViewModel 유저 플로우 기반 테스트 코드 자동 생성. Given-When-Then 패턴으로 Input/Output 검증 테스트를 생성합니다. XCTest와 Combine을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다. |
ViewModel의 유저 플로우를 기반으로 XCTest 테스트 코드를 자동 생성합니다. Given-When-Then 패턴을 따르며, Mock Repository를 사용하여 네트워크 의존성을 제거합니다.
App/Targets/three-dollar-in-my-pocketTests/
└── ViewModelTests/
├── ContributorsViewModelTests.swift
├── StoreDetailViewModelTests.swift
└── ...
{ViewModel이름}Tests.swift{ViewModel이름}Teststest_{when}_{then}()func test_사용자가로드버튼을탭하면_데이터가로드된다() async {
// Given: 초기 상태 설정
let mockRepository = MockStoreRepository()
mockRepository.fetchStoreContributorHistoriesResult = .success(mockResponse)
let dependency = ContributorsViewModel.Dependency(
storeRepository: mockRepository
)
let config = ContributorsViewModel.Config(storeId: 123, store: nil)
let viewModel = ContributorsViewModel(config: config, dependency: dependency)
var receivedItems: [SDUItem] = []
viewModel.output.items
.sink { items in
receivedItems = items
}
.store(in: &cancellables)
// When: 로드 액션 실행
viewModel.input.load.send(())
// Then: 결과 검증
await Task.yield()
XCTAssertFalse(receivedItems.isEmpty)
XCTAssertEqual(receivedItems.count, 3)
}
import XCTest
import Combine
@testable import three_dollar_in_my_pocket
final class ContributorsViewModelTests: XCTestCase {
private var cancellables = Set<AnyCancellable>()
override func tearDown() {
cancellables.removeAll()
super.tearDown()
}
// MARK: - 데이터 로드 테스트
func test_사용자가로드버튼을탭하면_데이터가로드된다() async {
// Given
let mockRepository = MockStoreRepository()
let mockResponse = StoreContributorHistoriesSection(
data: .init(
cursor: "next-cursor",
cards: [
.init(data: .callout(.init(
promptTitle: .init(spans: [.init(text: "테스트 제목")]),
description: .init(spans: [.init(text: "테스트 설명")]),
style: .init(backgroundColor: "#FFFFFF")
))),
.init(data: .iconText(.init(
icon: .init(imageUrl: "https://example.com/icon.png"),
text: .init(spans: [.init(text: "아이콘 텍스트")])
))),
.init(data: .callout(.init(
promptTitle: .init(spans: [.init(text: "두 번째 제목")]),
description: .init(spans: [.init(text: "두 번째 설명")]),
style: .init(backgroundColor: "#EEEEEE")
)))
]
)
)
mockRepository.fetchStoreContributorHistoriesResult = .success(mockResponse)
let dependency = ContributorsViewModel.Dependency(
storeRepository: mockRepository,
logManager: MockLogManager()
)
let config = ContributorsViewModel.Config(storeId: 123, store: nil)
let viewModel = ContributorsViewModel(config: config, dependency: dependency)
var receivedItems: [SDUItem] = []
viewModel.output.items
.sink { items in
receivedItems = items
}
.store(in: &cancellables)
// When
viewModel.input.load.send(())
// Then
await Task.yield()
XCTAssertFalse(receivedItems.isEmpty)
XCTAssertEqual(receivedItems.count, 3)
}
func test_네트워크에러가발생하면_에러메시지가전달된다() async {
// Given
let mockRepository = MockStoreRepository()
let expectedError = NSError(domain: "Test", code: -1, userInfo: nil)
mockRepository.fetchStoreContributorHistoriesResult = .failure(expectedError)
let dependency = ContributorsViewModel.Dependency(
storeRepository: mockRepository,
logManager: MockLogManager()
)
let config = ContributorsViewModel.Config(storeId: 123, store: nil)
let viewModel = ContributorsViewModel(config: config, dependency: dependency)
var receivedError: Error?
viewModel.output.error
.sink { error in
receivedError = error
}
.store(in: &cancellables)
// When
viewModel.input.load.send(())
// Then
await Task.yield()
XCTAssertNotNil(receivedError)
XCTAssertEqual((receivedError as NSError?)?.code, -1)
}
// MARK: - 화면 전환 테스트
func test_닫기버튼을탭하면_dismiss라우팅이발생한다() {
// Given
let mockRepository = MockStoreRepository()
let dependency = ContributorsViewModel.Dependency(
storeRepository: mockRepository,
logManager: MockLogManager()
)
let config = ContributorsViewModel.Config(storeId: 123, store: nil)
let viewModel = ContributorsViewModel(config: config, dependency: dependency)
var receivedRoute: ContributorsViewModel.Route?
viewModel.output.route
.sink { route in
receivedRoute = route
}
.store(in: &cancellables)
// When
viewModel.input.didTapClose.send(())
// Then
guard case .dismiss = receivedRoute else {
XCTFail("Expected dismiss route")
return
}
}
// MARK: - 페이지네이션 테스트
func test_추가로드시_커서가전달된다() async {
// Given
let mockRepository = MockStoreRepository()
let firstResponse = StoreContributorHistoriesSection(
data: .init(cursor: "first-cursor", cards: [])
)
let secondResponse = StoreContributorHistoriesSection(
data: .init(cursor: "second-cursor", cards: [])
)
mockRepository.fetchStoreContributorHistoriesResult = .success(firstResponse)
let dependency = ContributorsViewModel.Dependency(
storeRepository: mockRepository,
logManager: MockLogManager()
)
let config = ContributorsViewModel.Config(storeId: 123, store: nil)
let viewModel = ContributorsViewModel(config: config, dependency: dependency)
// When: 첫 번째 로드
viewModel.input.load.send(())
await Task.yield()
// Then: 커서가 저장되었는지 확인
mockRepository.fetchStoreContributorHistoriesResult = .success(secondResponse)
viewModel.input.loadMore.send(())
await Task.yield()
XCTAssertEqual(mockRepository.lastCursor, "first-cursor")
}
}
// MARK: - Mock Repository
final class MockStoreRepository: StoreRepository {
var fetchStoreContributorHistoriesResult: Result<StoreContributorHistoriesSection, Error>?
var lastCursor: String?
func fetchStoreContributorHistories(storeId: Int, cursor: String?) async -> Result<StoreContributorHistoriesSection, Error> {
lastCursor = cursor
guard let result = fetchStoreContributorHistoriesResult else {
return .failure(NSError(domain: "Mock", code: -1))
}
return result
}
// 다른 메서드들은 기본 구현 제공
func createStore(input: UserStoreCreateRequestV3, nonceToken: String) async -> Result<UserStoreResponse, Error> {
return .failure(NSError(domain: "Not implemented", code: -1))
}
// ... 나머지 메서드들
}
// MARK: - Mock LogManager
final class MockLogManager: LogManagerProtocol {
func sendPageView(screen: ScreenName, type: AnyClass) { }
func sendEvent(_ event: LogEvent) { }
}
final class MockMyRepository: MyRepository {
var fetchDataResult: Result<MyDataResponse, Error>?
var lastRequestInput: FetchDataInput?
func fetchData(input: FetchDataInput) async -> Result<MyDataResponse, Error> {
lastRequestInput = input // 호출 파라미터 저장
guard let result = fetchDataResult else {
return .failure(NSError(domain: "Mock", code: -1))
}
return result
}
}
데이터 로드: 사용자가 화면에 진입하면 데이터가 로드된다
func test_사용자가화면에진입하면_데이터가로드된다() async
추가 로드: 사용자가 스크롤하면 추가 데이터가 로드된다
func test_사용자가스크롤하면_추가데이터가로드된다() async
에러 처리: 네트워크 에러 발생 시 에러 메시지가 표시된다
func test_네트워크에러시_에러메시지가전달된다() async
화면 전환: 사용자가 버튼을 탭하면 다음 화면으로 이동한다
func test_버튼탭시_다음화면으로이동한다()
빈 데이터: 서버에서 빈 배열이 반환되면 적절한 UI를 표시한다
func test_빈데이터반환시_빈상태UI가표시된다() async
중복 요청 방지: 로딩 중 추가 요청이 발생하면 무시한다
func test_로딩중_추가요청은무시된다() async
빠른 연속 탭: 버튼을 빠르게 여러 번 탭해도 한 번만 실행된다
func test_빠른연속탭_한번만실행된다()
// When
viewModel.input.load.send(())
// Then
await Task.yield() // 비동기 처리 대기
XCTAssertFalse(receivedItems.isEmpty)
주의사항:
viewModel.input.load.send(())
await Task.yield()
viewModel.input.loadMore.send(())
await Task.yield()
var receivedItems: [SDUItem] = []
viewModel.output.items
.sink { items in
receivedItems = items
}
.store(in: &cancellables)
var receivedRoute: MyViewModel.Route?
viewModel.output.route
.sink { route in
receivedRoute = route
}
.store(in: &cancellables)
var receivedError: Error?
viewModel.output.error
.sink { error in
receivedError = error
}
.store(in: &cancellables)
XCTAssertTrue(condition)
XCTAssertFalse(condition)
XCTAssertEqual(value1, value2)
XCTAssertNotEqual(value1, value2)
XCTAssertNil(value)
XCTAssertNotNil(value)
XCTAssertFalse(receivedItems.isEmpty)
XCTAssertEqual(receivedItems.count, 3)
XCTAssertEqual(receivedItems.first?.id, expectedId)
guard case .dismiss = receivedRoute else {
XCTFail("Expected dismiss route")
return
}
guard case .pushDetail(let viewModel) = receivedRoute else {
XCTFail("Expected pushDetail route")
return
}
XCTAssertNotNil(viewModel)
XCTAssertNotNil(receivedError)
XCTAssertEqual((receivedError as NSError?)?.code, -1)
XCTAssertEqual((receivedError as NSError?)?.domain, "Test")
final class ContributorsViewModelTests: XCTestCase {
func test_로드시_3개아이템이반환된다() async {
// Given
let mockRepository = MockStoreRepository()
mockRepository.fetchStoreContributorHistoriesResult = .success(mockResponse)
let viewModel = ContributorsViewModel(
config: .init(storeId: 123, store: nil),
dependency: .init(storeRepository: mockRepository)
)
var receivedItems: [SDUItem] = []
viewModel.output.items
.sink { receivedItems = $0 }
.store(in: &cancellables)
// When
viewModel.input.load.send(())
// Then
await Task.yield()
XCTAssertEqual(receivedItems.count, 3)
}
}
App/Targets/three-dollar-in-my-pocketTests/
└── ViewModelTests/
├── ContributorsViewModelTests.swift
├── StoreDetailViewModelTests.swift
└── ...
네이밍:
{ViewModel이름}Tests.swift{ViewModel이름}Tests