클린아키텍처-클론코딩 (2) UseCase Test

Domain 계층을 완성했으니 테스트를 해보겠습니다. UseCase 중에서 SearchMoviesUseCase를 테스트 해볼겁니다. SearchMoviesUseCase는 excute 함수로 영화 목록을 서버로부터 가져오고, 영화 목록을 가져오는데 성공했다면 해당 쿼리를 저장합니다. 여기서 저희는 아직 Repository를 구현하지 않았습니다. 그렇다면 어떻게 UseCase를 테스트 할까요? 지금은 서버의 API 조차 모르는데 어떤식으로 서버로 부터 데이터를 가져오는 작업을 테스트를 할 수 있을까요?

 

우선은 테스트 함수를 만들어줍니다.

func testSearchMoviesUseCase_whenSuccessfullyFetchesMoviesForQuery_thenQueryIsSavedInRecentQueries() {

}

테스트 함수의 이름은 테스트의 내용을 알려주는 식으로 작성합니다. 현재 테스트 할 내용은 쿼리문으로 영화 목록을 가져오는것을 성공했을 경우, 해당 쿼리문을 저장하는것을 테스트 합니다.

 

우선은 DefaultSearchMoviesUseCase를 생성해보려고 합니다.

DefaultSearchMoviesUseCase(moviesRepository: <#T##MoviesRepository#>, moviesQueriesRepository: <#T##MoviesQueriesRepository#>)

그런데 DefaultSearchMoviesUseCase의 생성자는 Repository를 의존성 주입으로 사용합니다. 하지만 지금은 Repository를 구현하지 않은 상태입니다. 하지만 테스트를 하려면 Repository를 외부로부터 주입당해야 합니다. 이때 저희는 Mock이라는 가짜객체를 만들어 테스트에 사용합니다. 그래서 MoviesRepository와 MoviesQueriesRepository를 채택한 Mock객체들을 생성해주도록 하겠습니다.

struct MoviesRepositoryMock: MoviesRepository {
        var result: Result<MoviesPage, Error>
        func fetchMoviesList(query: MovieQuery, page: Int, cached: @escaping (MoviesPage) -> Void, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
            completion(result)
            return nil
        }
    }

또 result를 만들어 줍니다. 성공하는 경우니 MoviePage 배열을 직접 만들어 줍니다.

static let moviesPages: [MoviesPage] = {
        let page1 = MoviesPage(page: 1, totalPages: 2, movies: [
            Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
            Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2")])
        let page2 = MoviesPage(page: 2, totalPages: 2, movies: [
            Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")])
        return [page1, page2]
    }()

MoviesRepositoryMoc는 실제로 통신을 하는것이 아닌 서버로 부터 영화 목록을 가져오는 것을 성공 했다는 것을 전제로 한 테스트 이니 가져온 서버에서 가져온 데이터 result를 선언하고, completion으로 해당 값을 전달합니다.

class MoviesQueriesRepositoryMock: MoviesQueriesRepository {
        var recentQueries: [MovieQuery] = []
        
        func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void) {
            completion(.success(recentQueries))
        }
        func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void) {
            recentQueries.append(query)
        }
    }

또 MoviesQueriesRepositoryMock역시 실제 저장소가 아닌 배열을 통해 작동이 되는지 안되는지 테스트를 할 수 있도록 recentQueries를 선언해줍니다.

 

이제 DefaultSearchMoviesUseCase를 생성해줍니다.

let useCase = DefaultSearchMoviesUseCase(moviesRepository: MoviesRepositoryMock(result: .success(SearchMoviesUseCaseTests.moviesPages[0])),
                                                 moviesQueriesRepository: moviesQueriesRepository)

그리고 이제 요청을 위한 쿼리문을 작성합니다. SearchMoviesUseCase.swift 파일에 같이 구현을 해놨었죠.

let requestValue = SearchMoviesUseCaseRequestValue(query: MovieQuery(query: "title1"),page: 0)

그리고 이제 usecase를 사용하여 서버로부터 영화 목록을 가져오는 코드를 실행합니다.

_ = useCase.execute(requestValue: requestValue, cached: { _ in }) { _ in

        }

useCase의 excute와 그 안에서 영화를 검색하는 moviesRepository는 escaping 핸들러로 비동기적으로 데이터를 가져옵니다. 따라서 비동기적으로 데이터를 가져오는 상황을 테스트에서 구현해야 합니다. 여기서 XCTestExpectation을 사용합니다.

 

XCTestExpectation은 비동기 코드가 예상대로 작동하는지 테스트를 할 수 있게 해줍니다.

XCTestExpectation을 사용하는 방식에는 2가지가 있습니다. 

첫번째는 공식 문서에 나와있는 대로

let expectation = XCTestExpectation(description: "Recent query saved")

이런식으로 객체를 만들어서 사용하는 방식입니다. 

 

그리고 해당 프로젝트의 소스코드에는 

let expectation = self.expectation(description: "Recent query saved")

이렇게 expectation(description:)으로 XCTestExpectation을 생성 할 수 있습니다. 두가지 방식의 차이점은 뭔지는 모르겠으나 expectation(description:) 외에도 알람이 오거나, 특정 값과 일치할때 까지 등등 여러가지 옵션을 제공해 주고 있습니다. 

 

이런식으로 expectation을 생성 해 주고, useCase의 excute에 fullfill()을 사용합니다. fullfill은 작업이 완료 됬다는 의미입니다.

_ = useCase.excute(requestValue: requestValue, cached: { _ in}, completion: { _ in
            expectation.fulfill()
        })

서버로부터 영화 목록을 가져왔으면, moviesQueriesRepository에 이를 저장합니다. 저번에 구현한 useCase에서 가져오는데 성공했다면 moviesQueriesRepository의 saveRecentQuery를 통해 저장을 하도록 해두었죠.

//SearchMoviesUseCase.swift
func excute(requestValue: SearchMoviesUseCaseRequestValue, cached: @escaping (MoviesPage) -> Void, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        return moviesRepository.fetchMoviesList(query: requestValue.query, page: requestValue.page, cached: cached, completion: { result in
            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }
            completion(result)
        })
    }

또 useCase 객체를 생성할 때 MoviesRepository와 MoviesQueriesRepository의 Mock 객체를 주입해주었습니다. 

class MoviesQueriesRepositoryMock: MoviesQueriesRepository {
        var recentQueries: [MovieQuery] = []
        
        func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void) {
            completion(.success(recentQueries))
        }
        func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void) {
            recentQueries.append(query)
        }
    }

따라서 지금 recentQueries에 requestValue의 쿼리문이 저장되어 있는 상태일것입니다. 이걸 이제 moviesRepository의 fetchRecentQueries를 이용하여 저장된 쿼리문을 가져옵니다. 저장된 쿼리문을 가져오는 작업 역시 비동기적으로 작동합니다. 또한 데이터를 서버로 가져오고, 저장, 그리고 가져오는 하나의 흐름을 이루는 작업을 진행합니다. 그래서 XCTestExpectation을 새로 만드는게 아닌 비동기 작업의 개수를 지정해서 흐름이 이어지도록 합니다.

expectation.expectedFulfillmentCount = 2

var recents = [MovieQuery]()
moviesQueriesRepository.fetchRecentsQueries(maxCount: 1) { result in
            recents = (try? result.get()) ?? []
            expectation.fulfill()
        }

그리고 이 과정이 모두 끝날때 까지의 제한시간을 설정해줍니다.

waitForExpectations(timeout: 5, handler: nil)

5초 이내에 모든 fullfill이 완료 되지 않으면 error가 발생합니다. handler는 완료되거나 시간 초과가 됬을때 실행하는 블록 입니다. 

 

그리고 recent에 쿼리가 저장되있는지 확인합니다.

XCTAssertTrue(recents.contains(MovieQuery(query: "title1")))