티스토리 뷰
일반적인 동기 함수 테스트는 정말 간단합니다.
func test_signup_email_validation() {
// True
XCTAssertTrue(viewModel.isValidEmail("test@example.com"))
XCTAssertTrue(viewModel.isValidEmail("user@domain.co.uk"))
// Fasle
XCTAssertFalse(viewModel.isValidEmail("invalid_email"))
XCTAssertFalse(viewModel.isValidEmail("user@.com"))
XCTAssertFalse(viewModel.isValidEmail("@domain.com"))
}
함수의 input과 output이 명확하므로 손 쉽게 여러 Test case를 작성해볼 수 있습니다. 그러나, Combine으로 작성한 함수를 테스트하는 것은 조금 복잡했습니다.
동기/비동기
우선 대상이 동기인지, 비동기인지에 따라 테스트 방법이 조금 달라집니다. delay나 timer 기능을 테스트하는 상황이라던가, 실제 서버와 직접 통신하는 상황이 아닌 경우에는 코드가 동기로 동작하므로 동기 테스트를 하시면 됩니다. 보통 서버통신과 같은 비동기적인 부분은 모두 Mock으로 대체하므로 대부분 동기테스트를 한다고 볼 수 있습니다.
테스트 대상
class SignupViewModel: ObservableObject {
let accountService: AccountServiceType
// output
@Published var signupErrorMessage: String? = nil
// ...
func signup(_ signupModel: SignupModel){
accountService.requestSignup(signupModel: signupModel)
.sink(receiveCompletion: { [weak self] completion in
switch completion {
case .finished:
break
case .failure(let error):
self?.signupErrorMessage = error.krDescription()
break
}
}, receiveValue: { [weak self] result in
self?.signupErrorMessage = nil
})
.store(in : &cancellable)
}
}
테스트 해볼 대상은 ViewModel의 signup 함수입니다. AccountService는 ComineMoya로 구성되어 있고 테스트에선 Mock을 사용할 계획입니다. 테스트의 목적은 signup함수 호출 시 ErrorString이 잘 들어가는지 확인하는 것입니다. signup함수는 sink를 통해 반환값 없이 내부 클래스의 상태만 변화시키므로 내부 변수를 추적하는 방식으로 테스트하려고합니다.
👇 MockService 자세히
해당 Mock은 SignupModel을 받아 정상처리되면 json 응답을 담은 Just를 return하고 오류가 발생하면 Error를 담은 Fail을 return합니다.
enum MockEmail: String {
case duplicatedEmail = "duplicated@example.com"
}
class MockAccountService: AccountServiceType {
var cancellables = Set<AnyCancellable>()
func requestSignup(signupModel: SignupModel) -> AnyPublisher<Response, AccountError> {
// 중복된 이메일이면 에러 Fail return
if signupModel.email == MockEmail.duplicatedEmail.rawValue {
return Fail(error: AccountError.emailDuplicated)
.eraseToAnyPublisher()
}
// 응답 생성
let jsonDict: [String: Any] = ["mid": "-1"]
guard let jsonData = try? JSONSerialization.data(withJSONObject: jsonDict, options: []) else {
return Fail(error: AccountError.jsonSerializationFailed)
.eraseToAnyPublisher()}
// SignupModel에 따른 유효한 Response 값을 생성하여 AnyPublisher로 반환
let response = Response(statusCode: 200, data: jsonData)
// 성공시 응답을 방출하는 Just 생성
return Just(response)
.setFailureType(to: AccountError.self)
.eraseToAnyPublisher()
}
}
동기 테스트
Mock을 이용했으므로 MockService와 ViewModel에 비동기적인 동작을 하는 코드는 없습니다. 따라서 아래와 같이 테스트할 수 있습니다.
func test_signup_success() {
// Given
let publisher = viewModel.$signupErrorMessage // 테스트할 Publisher
var receivedValue: String?
let cancellable = publisher
.sink{ value in
// 이벤트 받을 시 저장
receivedValue = value
}
// When
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Then
XCTAssertEqual(receivedValue, nil) // 예상한 결과와 일치하는지 확인
// Clean up
cancellable.cancel() // 테스트가 끝나면 구독을 취소하여 리소스를 정리
}
1. Given
signup함수의 결과를 저장하는 publisher(signupErrorMessage), publisher를 구독해 받은 값을 저장할 recivedValue를 정의합니다. 그리고 publisher를 구독해 콜백을 등록하여 receivedValue가 채워지도록 합니다.
2. When
적절한 input값을 넣어 테스트 대상이 되는 함수를 호출합니다.
3. Then
적절한 값이 들어왔는지 확인합니다.
위 코드에서 signup 함수가 호출된 뒤(When), 내부적으로 signupErrorMessage를 변경했을 것이고, signupErrorMessage가 변경이 되면 이를 구독한 sink 클로저가 실행되어서 receivedValue를 받아볼 수 있습니다. 이 receivedValue를 통해 XCAssertEqual을 확인해주었습니다. XCAssertEqual외에도 XCAssertNotEqual, XCAssertTrue, XCAssertNil, XCAssertGreaterThan, XCAssertThrowsError 등등 많은 Assert를 제공하고 있으니 상황에 맞게 써주면 좋을 것 같습니다. (Assert 종류 참고: XCTest공식문서)
여러 테스트 케이스 검증하기
func test_signup_success() {
// Given
let publisher = viewModel.$signupErrorMessage // 테스트할 Publisher
var receivedValues: [String?]
let cancellable = publisher
.dropFirst()
.sink{ value in
// 이벤트 받을 시 저장
receivedValues.append(value)
}
// When
// Test case 1 - success
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Test case 2 - fail
viewModel.signup(SignupModel(email: MockEmail.duplicatedEmail.rawValue,
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Then
XCTAssertEqual(receivedValues, [nil, AccountError.emailDuplicated.krDescription()]) // 예상한 결과와 일치하는지 확인
// Clean up
cancellable.cancel() // 테스트가 끝나면 구독을 취소하여 리소스를 정리
}
만약 여러 테스트케이스를 한번에 검증해보고싶다면 위와 같이 receivedValue를 리스트로 바꾸어 대상 함수를 여러번 실행시킨 후에 Assert를 확인해볼 수 있습니다. 이때, @Published 프로퍼티래퍼는 구독시 가지고있던 값을 방출하므로 처음 이벤트는 버려주기 위해 dropFirst()를 써줍니다. 만약 대상 publisher가 PassthroughSubject라면 dropFirst를 하지 않아도 되겠죠?
비동기: XCTestExpectation의 fulfill()로 테스트
만약 비동기 함수를 테스트해야한다면 어떻게 해야할까요? 비동기 함수를 테스트하기 위해서는 XCTestExpectation의 fulfill()을 이용해야하는데요, 동기 예시에 fullfill()을 사용한 예시로 바꿔보았습니다. (특히 실제 디바이스 DB 읽기 / 서버 통신 테스트 / delay · timer 기능 테스트 시 꼭 이 방법으로 하셔야합니다. wait를 걸어주지 않으면 테스트 함수가 바로 종료되면서 콜백이 실행되지 않습니다.)
func test_signup_success() {
// Given
let expectation = XCTestExpectation(description: "Signup Test Test")
let publisher = viewModel.$signupErrorMessage // 테스트할 Combine Publisher
var receivedValue: String?
let cancellable = publisher
.sink{ value in
// 이벤트 받을 시 저장
receivedValue = value
expectation.fulfill()
}
// When
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Then
wait(for: [expectation], timeout: 2.0) // expectation을 기다려서 timeout 내에 fulfill이 되는지 확인
XCTAssertEqual(receivedValue, nil) // 예상한 결과와 일치하는지 확인
// Clean up
cancellable.cancel() // 테스트가 끝나면 구독을 취소하여 리소스를 정리
}
1. Given
signup함수의 결과를 저장하는 publisher(signupErrorMessage), publisher를 구독해 받은 값을 저장할 recivedValue, fulfill()을 이용할 expectation을 정의합니다. 그리고 publisher를 구독해 콜백을 등록합니다. 콜백 내부를 통해 receivedValue가 채워지게 하고 이후 완료 신호를 보낼 수 있도록 expectation.fulfill()을 호출합니다.
2. When
적절한 input값을 넣어 테스트 대상이 되는 함수를 호출합니다.
3. Then
fulfill()이 호출되기까지 최대 2.0초 기다립니다. 이후에도 fulfill()이 호출되지 않으면 timeout으로 테스트가 실패합니다. fulfill()이 잘 호출됐다면 이번엔 적절한 값이 들어왔는지 확인합니다.
만약 singup함수 내부가 비동기라면 위와 같은 방식으로 테스트를 진행해볼 수 있습니다. 테스트가 타임아웃으로 실패했는지, 결과값이 달라 실패했는지에 따라 적절히 코드를 수정하면 됩니다.
비동기 - 여러 테스트 케이스 검증하기
func test_signup_multi() {
// Given
let expectation = XCTestExpectation(description: "Signup Test")
let publisher = viewModel.$signupErrorMessage // 테스트할 Combine Publisher
var receivedValue: [String?] = []
// When
let cancellable = publisher
.dropFirst()
.sink{ value in
receivedValue.append(value)
expectation.fulfill()
}
// Test case 1
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Test case 2
viewModel.signup(SignupModel(email: MockEmail.duplicatedEmail.rawValue,
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Then
wait(for: [expectation], timeout: 2.0) // expectation을 기다려서 timeout 내에 fulfill이 되는지 확인
XCTAssertEqual(receivedValue, [nil, AccountError.emailDuplicated.krDescription()]) // 예상한 결과와 일치하는지 확인
// Clean up
cancellable.cancel() // 테스트가 끝나면 구독을 취소하여 리소스를 정리
}
마찬가지로 리스트를 이용해서 위와 같이 구성해보았는데요, 사실 이 코드에는 조금 문제가 있습니다. 바로 첫번째 Test case에서 fullfill이 나버리므로 timeout 시간 내에 두번째 Test case가 실행되지 않아도 Assert문으로 넘어가게 됩니다. 타임아웃으로 실패한 경우를 구별하려면 리스트의 길이를 보고 판단해야하므로 여러 테스트케이스를 테스트할 때는 판단하기가 어려워집니다.
https://betterprogramming.pub/testing-your-combine-publishers-8ccd6bd151b
다른 방법을 찾다 위 블로그 글에서 작성한 익스텐션을 조금 변형하여 사용해보았습니다.
익스텐션
extension XCTestCase {
/// https://betterprogramming.pub/testing-your-combine-publishers-8ccd6bd151b
typealias CompetionResult = (expectation: XCTestExpectation,
cancellable: AnyCancellable)
func expectValue<T: Publisher>(of publisher: T,
timeout: TimeInterval = 2,
file: StaticString = #file,
line: UInt = #line,
equals: [T.Output]) -> CompetionResult where T.Output: Equatable {
let exp = expectation(description: "Correct values of " + String(describing: publisher))
var mutableEquals = equals
let cancellable = publisher
.dropFirst() // Discard the default value as it is emitted as the first value
.sink(receiveCompletion: { _ in },
receiveValue: { value in
if value == mutableEquals.first {
mutableEquals.remove(at: 0)
if mutableEquals.isEmpty {
exp.fulfill()
}
}
else {
// Print log: number of test cases, received value, expected value
XCTContext.runActivity(named: "Receive Value") { _ in
print("Case: \(equals.count - mutableEquals.count) Value: \(value) Expect: \(mutableEquals.first)")
}
}
})
return (exp, cancellable)
}
}
동작 방식은 예상값을 리스트로 받고 값이 방출될 때마다 하나씩 비교하는 방식입니다. 비교가 완료되면 fulfill()을 호출합니다. 만약 타임아웃 시간 내에 비동기 동작이 모두 완료되지 않는다면 타임아웃으로 테스트가 실패하게 됩니다. 하지만 문제는 Assert문이 없고, 결과값이 틀리면 remove가 되지 않아 fullfill이 나지 않는 방식이므로 무조건 timeout으로만 실패하게 됩니다. 즉, 어떤 테스트 케이스가 실패했는지 알기가 어렵습니다. 그래서 이를 보완하기 위해 몇번째 테스트케이스인지, 받은 값은 무엇인지 로그를 찍는 코드를 추가해주었습니다.
사용 방법
func test_signup_with_extension() {
// Given
let publisher = viewModel.$signupErrorMessage // 테스트할 Combine Publisher
let result = expectValue(of: publisher,
equals: [nil,
AccountError.emailDuplicated.krDescription()])
// When
// Test case 1 - 성공
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Test case 2 - 실패
viewModel.signup(SignupModel(email: MockEmail.duplicatedEmail.rawValue,
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Then
wait(for: [result.expectation], timeout: 1)
result.cancellable.cancel()
}
테스트 코드 자체가 짧아져 가독성이 좋아지는 효과도 있었습니다. 다만, Assert문을 사용하지 않으므로 로그창에서 어떤 테스트케이스가 실패했는지 찾아봐야하는 불편함은 조금 있었습니다.
비동기 클로저 내에서 Assert하면 안될까?
func test_signup_async() {
// Given
let publisher = viewModel.$signupErrorMessage // 테스트할 Combine Publisher
// When
let cancellable = publisher
.sink{
// Then
XCTAssertEqual($0, nil) // 예상한 결과와 일치하는지 확인
}
// Test case 1
viewModel.signup(SignupModel(email: "1234@naver.com",
pw: "1111",
birthday: "010101",
gender: true,
name: "test_1234"))
// Clean up
cancellable.cancel() // 테스트가 끝나면 구독을 취소하여 리소스를 정리
}
wait을 사용하는게 sleep을 사용하는 듯한.. 별로인 느낌이 들어서 이렇게 구현할 순 없을지 고민해보았는데요, 문제점이 있었습니다. 첫번째로 이렇게 하면 여러 상황을 테스트할 수 없게 됩니다. 리스트를 사용할 수 없으므로 테스트 함수 자체를 여러개 구현해야하는 문제점이 있습니다. 두번째로는 비동기 완료보다 Assert가 먼저 평가되어 문제가 생길 수도 있다고하네요. (ChatGPT가 그러던데 왜인지 정확히는 모르겠습니다...🥲 왜그런걸까요?)
RxSwift는 더 쉽게 하던데...
RxSwift를 테스트하는 RxTest를 이용하면 createColdObservable과 scheduler를 이용해 더 간편히 테스트할 수 있던데 Combine엔 그런 기능이 없는 것 같습니다. 서드파티는 좀 더 알아봐야겠네요!
마치며
테스트 코드는 처음 작성해보았는데요, 의도치 않게 TDD도 하게 되었습니다. ViewModel을 완성하지 않은 상태에서 테스트 코드를 짜려고 하다보니 테스트 실패가 떠서 테스트 통과를 위해 구현을 완료해야했어요. 하고나서 보니 반쯤은 TDD였네요.ㅎㅎ 또 테스트 관점에서 생각하다보니 헷갈렸던 부분들이 많이 정리가 되었습니다. 응답값이 없는 성공은 어떻게 구분하지? 실패를 관리하는 상태와 성공을 관리하는 상태를 따로 두어야하나? 그럼 각각의 상태를 다시 view 상태에 바인딩해야하나? 응답 값 대신 화면전환을 하는 로직은 어떻게 테스트하지? error는 메세지를 화면에 표시해야하는데, error string을 그대로 쓰면 테스트 코드에서 실수할 것 같은데...하는 여러가지 고민들을 하다보니 ViewModel 코드들이 정리가 되어갔습니다.
👇 위 고민들에 대한 소소한 결론..
1. 응답없는 성공 테스트: error가 발생하면 error string에 무조건 표시하고 최종결과가 nil이면 성공-무사히 화면전환으로 친다.
2. 각각 Error 테스트: 예를 들어 중복된 이메일로 실패, 네트워크 문제로 실패, 그외 실패.. 가 있을 때 이걸 각각 테스트하려면 string을 작성해야하는데 ViewModel과 Tests에 각각 string을 작성하면 휴먼 에러가 있을 수 있으므로 enum에 description() 함수를 만들어 사용한다.
앞으로 여러 아키텍쳐에 대해서 테스트 관점으로도 생각해보고 TDD도 열심히 해보려고합니다. 테스트 코드 참 재밌는 친구였네요.
읽어주셔서 감사합니다.
ref.
https://www.swiftbysundell.com/articles/unit-testing-combine-based-swift-code/
https://betterprogramming.pub/testing-your-combine-publishers-8ccd6bd151b(익스텐션 참고)
https://minsone.github.io/programming/reactive-swift-unit-test-1(민소네님, [ReactiveX][RxSwift]Unit Test 1 - 핫 옵저버블과 콜드 옵저버블)
그 외 Combine 공식 문서 및 ChatGPT
'iOS' 카테고리의 다른 글
[Xcode] 문서 주석(Doc comments) 작성하기, DocC 만들기 (1) | 2023.05.10 |
---|---|
[iOS] Swift Concurrency 공식 문서 정리 (0) | 2023.04.26 |
[iOS] XCTest 에러: Cannot find type 'AnyViewModel' in scope (0) | 2023.04.06 |
[iOS] LaunchScreen 로고 비율 깨짐 문제 (0) | 2023.03.29 |
[iOS] HealthKit 데이터 구조 정리 (DataType/Sample/Identifier) (0) | 2023.03.29 |
- Total
- Today
- Yesterday
- Bloking/Non-bloking
- Swift Concurrency
- healthkit
- TestCode
- Flux
- SWM
- Architecture Pattern
- 프로그래머스
- RX
- combine
- 노션
- notion
- 코디네이터 패턴
- design pattern
- DocC
- programmers
- coordinator pattern
- 비동기/동기
- MVI
- 리액티브 프로그래밍
- MVVM
- ios
- Flutter
- 아키텍쳐 패턴
- MVC
- 소프트웨어마에스트로
- GetX
- reactive programming
- SwiftUI
- swift
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |