[스몰토크] Swift Stubbing 통합테스트
단위테스트는 알고리즘을 검증하는 테스트입니다.
통합테스트를 하기 위해 어떤 것들이 필요한 지 알아봅시다.
통합테스트
우리는 객체간 메시지 통신을 어떻게 확인할 수 있을까요...?
객체간 메시지 전달은 런타임에서 일어나기 때문에 런타임에서만 확인할 수 있습니다.
어떻게?
자바에서는 Mockito 라는 강력한 Testing 도구가 있습니다.
Mockito 에 대한 자세한 설명은 생략합니다.
한글로 자세한 설명은 여기를 확인해주세요.
객체통신망에서 테스트 객체에서 메시지를 보낸 뒤 이웃의 Mock 객체들이 메시지를 수신하였는지 확인합니다.
// interface 뿐 아니라 구체 클래스도 mock으로 만들 수 있다.
LinkedList mockedList = mock(LinkedList.class);
// stubbing
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());
// 첫 번째 element를 출력한다.
System.out.println(mockedList.get(0));
// runtime exception이 발생한다.
System.out.println(mockedList.get(1));
// 999번째 element 얻어오는 부분은 stub되지 않았으므로 null이 출력
System.out.println(mockedList.get(999));
// stubbing 된 부분이 호출되는지 확인할 수 있긴 하지만 불필요한 일입니다.
// 만일 코드에서 get(0)의 리턴값을 확인하려고 하면, 다른 어딘가에서 테스트가 깨집니다.
// 만일 코드에서 get(0)의 리턴값에 대해 관심이 없다면, stubbing되지 않았어야 합니다. 더 많은 정보를 위해서는 여기를 읽어보세요.
verify(mockedList).get(0)
어떤것들이 있죠?
Mocking, Network Stubbing 등 단계별로 알아봅시다.
Mocking
Cuckoo
Cuckoo 는 제가 사용하는 라이브러리입니다.
자바의 Mockito 와 Interface 가 친숙하고 MVVMTDD 예제에서도 Cuckoo 를 사용하였습니다.
Stubber
역시 전수열님의 오픈소스를 뺄 수 없을 것 같습니다.
Stubber 는 전수열님이 오픈소스로 개발하신 라이브러리입니다.
어떻게 활용할 수 있을까?
가장 간단한 예제는 MVVMTDD 를 참고하시면 됩니다.
약간은 복잡한 형태의 2가지를 소개할려고 합니다.
1. List Paging Stubbing
아신의 코드리뷰를 진행하면서 Github Search 의 간단한 앱을 ReactorKit 을 활용하여 Paging 기능이 포함된 코드리뷰 건이 있었습니다.
과연 Paging 을 Stubbing 할 수 있을까요?
대답은 YES 입니다.
아래 테스트코드는 Paging 기능을 검증하였던 코드입니다.
1번의 Fetch 그리고 2번의 Load More 가 발생했지만 총 7번의 Event 가 발생하였습니다.
func testUserItemPaging() {
// 페이징 동작이 잘 동작하는 지 검증
let expect = [
[Fixture.UserItems.sampleUserItems.shuffled().first!],
[Fixture.UserItems.sampleUserItems.shuffled().first!],
[Fixture.UserItems.sampleUserItems.shuffled().first!]
]
reset(api)
api.setUserItemsPaging(expect)
let rxExpect = RxExpect()
rxExpect.retain(reactor)
rxExpect.input(reactor.action, [
.next(0, .updateQuery("a")),
.next(10, .loadNextPage),
.next(20, .loadNextPage),
])
// Reivew: [성능] NextPage를 2번 가지고 오면 총 7번의 연산이 들어갑니다.
// 3번의 Action을 수행했기 때문에 3번만 Item을 가지고 올 수 있도록 전략이 필요합니다.
// 배열의 크기가 커지면 치명적인 성능저하가 일어납니다.
rxExpect.assert(reactor.state.map { $0.userItems }.filterEmpty()) { events in
XCTAssertEqual(events.count, 7)
XCTAssertEqual(events[0], .next(0, expect[0]))
XCTAssertEqual(events[1], .next(10, expect[0]))
XCTAssertEqual(events[2], .next(10, expect[0] + expect[1]))
XCTAssertEqual(events[3], .next(10, expect[0] + expect[1]))
XCTAssertEqual(events[4], .next(20, expect[0] + expect[1]))
XCTAssertEqual(events[5], .next(20, expect[0] + expect[1] + expect[2]))
XCTAssertEqual(events[6], .next(20, expect[0] + expect[1] + expect[2]))
}
}
1 | First Fetch |
2 | Loading(True), First Fetch |
3 | Second Fetch |
4 | Loading(False), Second Fetch |
5 | Loading(True), Second Fetch |
6 | Third Fetch |
7 | Loading(False), Third Fetch |
눈치채신 분들도 계셨겠지만 Loading 상태값이 변경될 때마다 이전에 Fetch Data 의 Event 를 전달하고 있습니다.
이 점에 대해 성능 Comment 를 드렸습니다.
Cuckoo 를 사용하였으며 Stubbing 코드도 상당히 간단합니다.
@discardableResult
func setUserItemsPaging(_ items: [[UserItem]]) -> [[UserItem]] {
stub(self, block: { mock in
for (index, item) in items.enumerated() {
when(mock.fetchUsers(with: any(), page: equal(to: index + 1))).thenReturn(Observable.just((item, index + 2)))
}
})
return items
}
아이디어는 fetchUsers 를 호출할 때 전달되는 Page 에 따라 미리 정해둔 Data 를 전달하는 것입니다.
Page 1 | First |
Page 2 | Second |
Page 3 | Third |
2. Networking Stubbing
통합테스트를 넘어서 시스템 테스트로 넘어가는 과정에서 Stubbing 은 한계가 존재합니다.
그렇타면 어떻게 완전한 테스트 환경을 구축할 수 있을까요?
아이디어는 GET 방식으로 http://localhost:8080/Your-Path 호출하면 미리 정해둔 응답값을 전달해 주는 것입니다.
guard let gitUrl = URL(string: "https://api.github.com/users/shashikant86") else { return }
var stub = StubRequest(method: .GET, url: gitUrl)
var response = StubResponse()
...
stub.response = response
Hippolyte.shared.add(stubbedRequest: stub)
Hippolyte.shared.start()
Mockingjay 의 예제
guard let gitUrl = URL(string: "https://api.github.com/users/shashikant86") else { return }
self.stub(uri("https://api.github.com/users/shashikant86"), jsonData(data as Data))
위에 3개의 라이브러리는 정말로 테스트를 위한 테스트 라이브러리인 것 같습니다.
우리가 원하는 것은 Production Code 를 오염을 시키지 않으면서 Networking Stubbing 을 하는 것입니다.
UnitTest 에서는 사용할 순 없으며 UI Test 에서만 사용할 수 있을 것 같습니다.
그 이유는 launchEnvironment -> ProcessInfo.processInfo.environment 는 UITest 에서만 지정할 수 있기 때문입니다.
// UITest
app.launchEnvironment = ["BASEURL" : "http://localhost:8080"]
// Production Code
let baseUrl = ProcessInfo.processInfo.environment["BASEURL"]!
guard let gitUrl = URL(string: baseUrl + "/users/shashikant86") else { return }
마지막으로
Vapor, Kitura 는 Swift Server Framework 입니다.
테스트를 실행하기 전 Swift Server 를 실행할 수 있습니다.
그러나 한번 실행하기 위해 빌드에 소요되는 시간이 길기 때문에 최후의 수단으로 사용할 수 있을 것 같습니다.
cd ~/ProjectDIR/SwiftServerTestUITests/Server
swift build
swift run
Fixture
Fixture는 어떻게 관리해야 깔끔할까요?
이 부분에 대해서 Json 파일을 deserialization 한다면 조금 더 관리하기 편할 수 있습니다.
import Foundation
@testable import MyGithubUserSearch
struct Fixture {
struct UserItems {
static let sampleUserItems: [UserItem] = ResourcesLoader.loadJson("sample_user_item")
static var first: UserItem {
return sampleUserItems.first!
}
}
struct Organizations {
static let sampleOrganization: [OrganizationItem] = ResourcesLoader.loadJson("sample_organization")
static var first: OrganizationItem {
return sampleOrganization.first!
}
}
}
참조:
https://github.com/mockito/mockito/wiki/Mockito-features-in-Korean
https://youtu.be/Qa3dRrSbeQI?list=PLBNdLLaRx_rI-UsVIGeWX_iv-e8cxpLxS&t=3867