맛동산이

reactorkit에서 testcode 작성 하는법(feat. nimble) 본문

앱/Swift

reactorkit에서 testcode 작성 하는법(feat. nimble)

진ddang 2025. 1. 31. 08:46

먼저 테스트 코드의 장점과 단점 이런글보다는

이번글은 좀더 리액터킷의 테스트 코드를 짜기 위한 방법론에 가까운 글이다.

무작정 한번 알아보자~

 

 

테스트 코드 작성하는 방법

테스트 케이스 이름 작성법

test_유닛이름(구조체, 클래스 등)_유닛내부(프로퍼티, 메서드 등)_예상동작

맨 앞에 test는 꼭 붙여줘야 테스트를 실행할 수 있다.

 

테스트 케이스 구조는 다음과 같다.

 

1. Given(주어진 상황)

테스트 케이스에서 테스트할 상황을 설정하는 부분

테스트에 필요한 초기 조건을 설정하고 입력값을 준비

테스트 환경 설정 or 객체를 생성하는 등의 작업을 수행함

 

2. When(동작)

테스트할 동작이나 메서드를 호출하는 부분

테스트 대상이 되는 메서드나 특정 동작을 수행하고 그 결과를 확인

 

3. Then(결과 확인)

테스트의 예상 결과를 확인하는 부분으로 예상한 결과와 실제 결과를 비교한다.

XCTAssert 메서드를 활용해서 특정 조건을 확인하고, 테스트의 성공 여부 판단함

  • 메소드
    • XCTAssertEqual: 두 값이 서로 동일한지 확인. 예상 값과 실제 값이 같은 경우 테스트를 통과하고, 다른 경우에는 실패.
    • XCTAssertNotEqual: 두 값이 서로 다른지 확인. 예상 값과 실제 값이 다른 경우 테스트를 통과하고, 같은 경우에는 실패.
    • XCTAssertTrue: 조건이 true인지 확인. 주어진 조건이 true인 경우 테스트를 통과하고, false인 경우에는 실패.
    • XCTAssertFalse: 조건이 false인지 확인. 주어진 조건이 false인 경우 테스트를 통과하고, true인 경우에는 실패.
    • XCTAssertNil: 값이 nil인지 확인. 값이 nil인 경우 테스트를 통과하고, nil이 아닌 경우에는 실패.
    • XCTAssertNotNil: 값이 nil이 아닌지 확인. 값이 nil이 아닌 경우 테스트를 통과하고, nil인 경우에는 실패.
    • XCTAssertThrowsError: 특정 코드 블록에서 에러가 발생하는지 확인. 코드 블록에서 에러가 발생하면 테스트를 통과하고, 에러가 발생하지 않는 경우에는 실패.
    • XCTAssertNoThrow: 특정 코드 블록에서 에러가 발생하지 않는지 확인. 코드 블록에서 에러가 발생하지 않으면 테스트를 통과하고, 에러가 발생하는 경우에는 실패.
    • XCTAssertGreaterThan: 첫 번째 값이 두 번째 값보다 큰지 확인. 큰 경우 테스트를 통과하고, 작거나 같은 경우에는 실패.
    • XCTAssertLessThan: 첫 번째 값이 두 번째 값보다 작은지 확인. 작은 경우 테스트를 통과하고, 크거나 같은 경우에는 실패.
    • XCTAssertGreaterThanOrEqual: 첫 번째 값이 두 번째 값보다 크거나 같은지 확인. 크거나 같은 경우 테스트를 통과하고, 작은 경우에는 실패.
    • XCTAssertLessThanOrEqual: 첫 번째 값이 두 번째 값보다 작거나 같은지 확인. 작거나 같은 경우 테스트를 통과하고, 큰 경우에는 실패.

 

 

리액터 킷에서의 테스트 코드

리액터 킷의 구조를 도식화 하면 다음과 같은 그림이 된다.

이러한 구조에서 리액터에서는 총 두가지 테스트를 할수 있다.

  • View
    • Action: 유저의 액션에 맞는 동작이 reactor로 가는지
    • State: 상태의 변화에 따른 화면이 잘 변화했는지
  • Reactor
    • State: 액션에 의한 상태값이 잘 변화 되었는지

 

 

실제로 작성 시작하기

시작하기 앞서서 import 를 하나 해주어야한다.

@testable import 프로젝트이름

해당 코드를 통해서 프로젝트코드에 접근할수 있도록 하고, Test를 가능하게 한다.(일일히 타겟 추가 안해도됨)

 

 

TestCode 무작정알아보기

Xcode 11.4 버젼부터 Unit test를 생성하면 기본적으로 setUpWithError() , tearDownWithError() 로 생성이 되게된다. 물론 setUp()과 tearDown()도 override 해서 사용할 수 있다.

XCTest의 호출 순서 : setUpWithError() → setUp() → tearDown() → tearDownWithError()

차이는 setUp과 tearDown에서 에러를 do catch문이나 try? 를 사용하지 않더라도 에러를 발생시킬수있다.

여기에서 setUp과 tearDown을 알아보자

 

sut, setUp, tearDown

sut 은 일반적으로 sut는 테스트하려는 함수, 메소드, 클래스 또는 모듈 등을 지칭한다.

setUp() 은 test case에 있는 각각의 test method를 실행하기 전에 모든 상태를 reset해주는 함수이다.

tearDown() 은 test case에 있는 각각의 test method들이 끝나고 난 뒤에 cleanup을 수행해주는 함수이다.

우리의 경우에는 reactorkit을 사용하고 있고 sut은 Reactor가 될 것이다.

위의 구조로 짜진 예시코드는 다음과 같다.

func testIsLoading() {
    // given
    let scheduler = TestScheduler(initialClock: 0) // 1
    let sut = MyReactor()
    let disposeBag = DisposeBag()

    // when
    scheduler
    .createHotObservable([ // 2
        .next(100, .refresh) // 3
    ])
    .subscribe(sut.action) // 4
    .disposed(by: disposeBag)

    // then
    let response = scheduler.start(created: 0, subscribed: 0, disposed: 1000) { // 5
        sut.state.map(\\.isLoading)
    }
    XCTAssertEqual(response.events.map(\\.value.element), [ // 6
        false, // initial state
        true,  // just after .refresh
        false  // after refreshing
    ])
}

위 코드를 보면

  1. 가상 시간을 사용하는 TestScheduler를 이용해서 RxSwift의 코드를 테스트한다.
  2. 스케줄러는 정해진 시간에 hotObservable을 방출한다.
  3. .createdHotObservable을 할때 시퀀스(배열)을 만들어서 값을전달할때 다음과 같은 값을전달한다
    1. onNext: 시간과, 값 전달
    2. onError: 시간과 에러를 전달
    3. onCompleted: 시간 전달
  4. 해당 observable을 구독하여 Reactor 에 Action으로 전달한다.
  5. response
    1. 스케줄러를 실행하고, 이벤트를 기록한 옵저버를 반환한다.
  6. XCTAssertEqual을 통해서 수신한 이벤트를 검증한다.
    1. 위의 코드에서는 isLoading의 값이 맞는지 검증

 

 

Nimble

https://github.com/Quick/Nimble/blob/main/Sources/Nimble/Nimble.docc/Guides/Concurrency.md

Nimble의 공식문서

nimble은 XCTest의 메소드들을 래핑해두고, 쉽게 사용할수 있도록 만든 라이브러리이다.

nimble을 사용한 대표적인 예시코드는 다음과 같다.

expect(1 + 1).to(equal(2))
expect(1.2).to(beCloseTo(1.1, within: 0.1))
expect(3) > 2
expect("seahorse").to(contain("sea"))
expect(["Atlantic", "Pacific"]).toNot(contain("Mississippi"))
expect(ocean.isClean).toEventually(beTruthy())

위의 내용을 참고하여 실제로 Reactorkit의 테스트코드를 작성해 봅시다.

실제로 Reactorkit의 테스트 코드

https://github.com/ReactorKit/ReactorKit?tab=readme-ov-file#testing

해당 테스트 코드는 네트워크를 포함하지 않는 테스트 코드를 예시로 듭니다.

import XCTest
import RxTest
import RxSwift
import Nimble

@testable import 프로젝트명

final class Test: XCTestCase {
	typealias Reactor = Reactor파일명
	typealias Action = Reactor.Action
	typealias State = Reactor.State

	var sut: Reactor!
	var disposeBag: DisposeBag!
	var scheduler: TestScheduler!
	var observer: TestableObserver<State>
	
	override func setUp() {
		sut = Reactor()
		disposeBag = DisposeBag()
		scheduler = TestScheduler(initialClock: 0)
		observer = scheduler.createObserver(State.self)
	}
	
	override func tearDown() {
		sut = nil
		disposeBag = nil
		scheduler = nil
		observer = nil
	}
}

위의 먼저 세팅 코드를 한번 살펴보자

위에서 언급했던 기본 세팅 setUp과 tearDown의 메소드가 정의되어있다.

이때 주의해서 봐야하는건 scheduler, observer이다.

  • scheduler는 stream을 방출하기 위한 비동기 동작이라고 생각하면된다. 우리가 버튼을 누르고, 혹은 어떠한 동작을 랜덤하게 진행하게 되는데 이러한 랜덤한 시간을 설정할수 있게 해준다.
  • observer는 scheduler의 이벤트를 방출한것을 구독하는것 그리고 그 리턴값을 다시 관찰하기 위한 observer이다.

아래의 테스트 코드를 보면 좀더 이해가 쉬운데

실제로 작성된 테스트 코드는 다음과 같다.

func testViewWillAppear() throws {
	scheduler.createHotObserver([
		.next(210, Action.viewWillAppear),
		.next(....)
		...
	])
	.subscribe(sut.action)
	.disposed(by: disposeBag)
	
	sut.state
		.subscribe(observer)
		.disposed(by: disposeBag)
	
	scheduler.start()
	
	let expectReult = [예상 결과값]
	let result = observer.map(\\.value.element)
	let 원하는실제 state = result.map(\\.?.원하는 실제 state)
	
	expect(원하는state).to(equal(expectResult))
}

위의 코드를 순서대로 보면

  1. scheduler의 시간을 설정해주고 이벤트를 같이 방출한다.
  2. 방출된이벤트는 reactor의 액션으로 동작한다.
  3. mutation으로 방출된 state를 observer가 구독한다.
  4. 스케줄러가 실행된다. (실제로 이벤트 방출시작)
  5. 해당값을 배열로 받아서 기대값과 비교한다

의 과정을 거치게 된다.

이때 setUp의 초기화를 여기 함수내부에서 해도 상관은 없다. (given이 되겠죠)

이처럼 어떠한 동작을 통해서 기대하는 결과 state를 혹은 UI를 확인하는과정이 ReactorKit의 테스트 코드가 되겠다.

 

 

 

위 기존 코드의 문제점

실제로 저러한 방법으로 테스트 하는 경우에는 Reactorkit에서 State를 Pulse로 감쌌을 때 이벤트가 다음과 같이 방출되는걸 확인하였다.

예를 들어

//state
@Pulse var isLoading = false

.concat([
	.just(.updateIsLoading(true))
	.just(.updateIsLoading(false))
])

라고 할때 예상하는 값은

false ture false이다. (초기값을 포함한)

하지만 실제 값은 false true true true false로 방출되는것을 확인할수 있는데

이는 state가 reduce코드를 보면

reduce () -> State {
	var newState = state
	switch action {
		case let.updateIsLoading(isLoading):
			state.isLoading = isLoading
	}
	return newState
}

이러한 방법으로 여러개의 다른 액션들 또한 방출되기 때문일것이다.

 

 

 

해결법

https://brunch.co.kr/@d2e0f07c7ddb454/1

이를 해결하기위해서는 위의 블로그를 참고해서 코드를 작성하면된다.

개선된 코드

func testFucntion() throws { 
	sut = Reactor()
	scheduler.createHotObservable([
		.next(210, Action.actionName)
	])
	.subscribe(sut.action)
	.disposed(by: disposeBag)
	
	let isLoading = scheduler.start(created: 0, subscribed: 0, disposed: 1000) {
		self.sut.pulse(\\.$isLoading)
	}
	
	let expectedIsLoading: [Bool]? = [false, true, false]
	let result = isLoading.event.map(\\.value.elemnt)
	
	expect(expectedIsLoading).to(equal(result))
}

위의 코드를 보면 scheduler에서 state를 pulse로 받기 때문에 우리가 원하는 동작을 한다.

또한 위의 코드를 보면, create에서 클로저로 어떠한 이벤트를 create할지 받게되는데 이 코드를 확인해보면

public func start<Element, OutputSequence: ObservableConvertibleType>(
	created: TestTime,
	subscribed: TestTime,
	disposed: TestTime,
	created: @escaping () -> OutputSequence)
) -> TestableObserver<Element> where OutputSequence.Element == Element {
	var source: Observable<Element>?
	var subscription: Disposable?
	let observer = self.createdObserver(Element.self)
	_ = self.schedulerAbsoulteVirtual((), time: created) { _ in 
		source = created().asObserver()
		return Disposable.created()
	}
	
	_ = self.schedulerAbsoulteVirtual((), time: subscribed) { _ in 
		subscribed = source!.subscribe(observer)
		return Disposable.created()
	}
	_ = self.schedulerAbsoulteVirtual((), time: disposed) { _ in 
		subscription!.disposed()
		return Disposable.created()
	}
	self.start()
	return observer
}

를 통해서 내부적으로 observer를 생성하고 구독하는 그러한과정을 거치기 때문이다.

이를 통해서 isLoading.event.map(\\.value.elemnt) 의 코드를 보면

결국 return 되는것은 TestableObserver 타입이며 해당 타입은 onNext, onError, onComplete 를 event타입으로 가지고 있고

해당 타입에서 onNext에서만 element를 가지고 있기때문에

내가 보고자 하는것은 TestableObserver에서 방출된 이벤트 중에서 onNext에서 방출된 값의 배열을 보고 싶다.

라고 작성된것이다.

이를 통해서 우리가 실제로 확인하고자 하는 pulse값과 동일한 값을 확인할수 있다.

반응형