@Heebeom Kim

iOS Clean Architecture with TDD #2. Entities & Use Cases

이번엔 지난 포스트에서 생성한 MovieBook 프로젝트에서 다른 계층의 영향을 받지 않는 Domain을 개발할 것이다. 추후 과정은 외부 영향도가 적은 내부 계층부터 외부 계층순으로 진행한다. 의존성을 가진 계층을 먼저 개발하게 되면 명확하지 않은 의존 객체의 명세에 의해서 불필요한 변경이 생겨날 수 있다.

준비

Cocoapods을 이용해 우리가 사용할 라이브러리를 추가할 것이다. 우선 당장 필요한 Rx와 테스팅 라이브러리만 추가하자. Quick은 iOS에서 XCTest 대신 많이 사용하는 테스트 프레임워크이다. Nimble과 함께 많이 사용된다.

platform :ios, '13.0'
workspace 'MovieBook'

inhibit_all_warnings!
use_frameworks!

def rx
  pod 'RxCocoa'
  pod 'RxCodable'
  pod 'RxSwift'
  pod 'RxSwiftExt'
  pod 'RxGesture'
end

def testing
  pod 'Quick'
  pod 'Nimble'
  pod 'Cuckoo' # mocking framework
  pod 'RxTest'
  pod 'RxBlocking'
end

target 'MovieBook' do
  project 'MovieBook/MovieBook'
  rx

  target 'MovieBookTests' do
    
  end
end

target 'MovieDomain' do
  project 'Movie/Movie'
  rx

  target 'MovieDomainTests' do
    rx
    testing
  end
end

Entity

MovieBook에 필요한 가장 중요한 데이터 Movie이다. API에 따라 Entity의 형태를 결정하는 상황을 여러 번 경험했는데, Domain에서 중요한 부분을 담당하는 Entity가 API 모델에 의존하는 방식은 이상적이지 않다. 응답이 바뀔 때마다 Entity가 바뀌는 상황을 겪어보면 변화에 얼마나 취약한 지 알 수 있다. 그렇기에 API 모델은 반드시 따로 분리해야 한다.

public struct Movie: Equatable {
  public let id: String
  public let title: String
  public let description: String
  public let director: String
  public let rating: Double
  public let posterUrl: String
  public let marked: Bool
  
  public init(
    id: String,
    title: String,
    description: String,
    director: String,
    rating: Double,
    posterUrl: String,
    marked: Bool
  ) {
    self.id = id
    self.title = title
    self.description = description
    self.director = director
    self.rating = rating
    self.posterUrl = posterUrl
    self.marked = marked
  }
}

Movie가 추후에 class로 바뀔 순 있지만, 우선은 struct로 만들었다. 그리고 테스트엔 기본으로 Equality 체크가 들어가니 Equatable 프로토콜을 따르게 하자.

Use Case

Usecase비즈니스 로직이 실행되는 곳이다. 영화 정보를 가져오기 위한 GetMoviesUsecase와 영화 북마크를 할 수 있는 SetBookmarkUsecase 두 케이스만 추가를 해보자.

Async Handling

Usecase의 책임 중 하나는 Repository에서 데이터를 가져오는 것이다. 데이터를 받아오는 과정은 비동기를 기본으로 한다. Rx 이전에는 Completion Block을 넘기는 방식으로 비동기를 많이 다루었지만 이번에는 Rx를 사용해서 가독성 있는 코드를 작성할 것이다. 🚀

Repository

앞서 언급했던 것처럼 Domain 계층의 독립성을 위해 Repository에 대한 정의는 Domain에 위치하지만 구현체는 Data에 있다. MovieRepository를 Domain에 추가하자.

import RxSwift

public protocol MovieRepository {
  func getMovies() -> Observable<[Movie]>
  func setBookmark(movie: Movie, mark: Bool) -> Single<Bool>
}

getMovies는 추후 Advanced refresh를 위해서 이벤트가 여러 번 전달될 수 있는 Observable로 정의하였고 setBookmark는 단발성 이벤트를 전달하는 Single로 정의하였다.

테스트 준비

앞서 정의한 Usecase에 대한 테스트 코드를 작성할 것이다. 우린 아직 MovieRepository만 추가했기 때문에 테스트에 필요한 나머지 부분은 모두 Cuckoo로 Mocking을 할 것이다.

Cuckoo는 컴파일 타임에 원하는 클래스나 프로토콜에 대한 Mock Object를 생성해 주는 Mocking framework이다. 다양한 유스케이스를 테스트할 때 굉장히 유용하게 사용할 수 있다.

우선 Cuckoo로 Mock Object를 생성하기 위해서 스크립트를 추가해야 한다.

clean architecture 2 1 MovieDomainTests 타겟의 Build Phases에 스크립트를 추가한다.

테스트를 작성하고자 하는 타겟(MovieDomainTests)의 Build Phases로 이동해서 아래의 스크립트를 추가하자(Compile Sources Phase 위에 추가해야 한다). 디렉터리 이름은 각자에 맞게 조금씩 수정하면 된다.

OUTPUT_FILE="./${PROJECT_NAME}DomainTests/Supporting/MovieDomainMocks.swift"
echo "Generated Mocks File = ${OUTPUT_FILE}"

INPUT_DIR="./${PROJECT_NAME}Domain"
echo "Mocks Input Directory = ${INPUT_DIR}"

# Generate mock files, include as many input files as you'd like to create mocks for.
"${PODS_ROOT}/Cuckoo/run" generate --testable "MovieDomain" \
--output "${OUTPUT_FILE}" \
"${INPUT_DIR}/Repositories/MovieRepository.swift"

빌드를 한번 돌려보면 우리가 OUTPUT_FILE 로 지정한 곳에 ooMocks.swift 파일이 생겨났을 것이다. 이것을 아래처럼 프로젝트에 포함시키자.

clean architecture 2 2


앞으로 Mocking이 필요한 클래스를 위의 스크립트에서 맨 아랫줄에 하나씩 추가하면서 테스트를 진행할 것이다.

✨ 테스트 작성

MovieDomainTestsMovieDomain 모듈과 똑같이 미러링 해서 GetMoviesUsecaseGetMoviesUsecaseSpec를 추가하자. Quick의 네이밍 컨벤션에 따라 GetMoviesUsecaseSpec로 생성한다.

clean architecture 2 3

이제 spec() 안에 우리가 테스트할 코드를 작성할 것이다. 테스트 대상인 usecase를 생성하고 usecase가 참조하는 repository를 Mock으로 생성해서 주입한다.

import Quick
import Nimble
import Cuckoo
import RxCocoa
import RxSwift
import RxTest
import RxBlocking
@testable import MovieDomain

class GetMoviesUsecaseSpec: QuickSpec {
  override func spec() {
    var usecase: GetMoviesUsecase!
    var movieRepository: MockMovieRepository!
    
    beforeEach {
      movieRepository = MockMovieRepository()
      usecase = GetMoviesUsecase(repository: movieRepository)
    }
  }
}

⚠️ 아직 GetMoviesUsecase 클래스를 만들지 않았기 때문에 당연히 빨간 줄이 표시된다. Welcome to TDD! 앞으로 빨간 줄을 하나하나 잡아나갈 것이다.

beforeEach는 각 테스트가 동작하기 전에 실행되는 블록이다. 이곳에서 주로 객체를 초기화하는 작업을 수행한다.


이제 오류를 없애야 할 차례이다. GetMoviesUsecase.swift 파일로 가서 초기화할 때 MovieRepository를 주입받는 GetMoviesUsecase 클래스를 만들자.

public class GetMoviesUsecase {
  private let repository: MovieRepository
  
  public init(repository: MovieRepository) {
    self.repository = repository
  }
}

오류가 사라졌으니 실제 테스트 코드를 작성할 것이다. GetMoviesUsecase를 실행 시 영화 리스트를 잘 가져오는지 테스트할 것이다. 테스트를 작성할 때에는 아래의 단계를 지켜서 기술하면 파악하기가 좋다.

override func spec() {
    var usecase: GetMoviesUsecase!
    var movieRepository: MockMovieRepository!

    beforeEach {
      movieRepository = MockMovieRepository()
      usecase = GetMoviesUsecase(repository: movieRepository)
    }

    let ironman = Movie(
      id: "1",
      title: "아이언맨",
      description: "empty",
      director: "Jon Favreau",
      rating: 4.9,
      posterUrl: "empty",
      marked: false
    )
    let movies = [ironman]

    it("should get movies from the repository") {
      // given (arrange)
      stub(movieRepository) { repository in
        when(repository.getMovies()).thenReturn(Observable.just(movies))
      }

      do {
        // when (act)
        let result = try usecase.execute()
          .toBlocking()
          .toArray()

        // then (assert)
        expect(result[0]).to(equal(movies))
        verify(movieRepository, times(1)).getMovies()
      } catch {
        fail()
      }
    }
  }

테스트 코드를 작성할 때 3 단계로 나눌 수 있다.

  • 준비(Arrange) — 테스트 코드를 실행하기 전에 적절한 상태로 객체들을 생성하거나 인터랙션이 발생하는 경우 외부의 영향을 받지 않기 위해 가짜로 대체한다.
  • 실행(Act) — 테스트 코드를 실행한다. 보통은 단일 메서드를 호출한다.
  • 단언(Assert) — 실행한 코드가 기대한 대로 동작하는지 확인한다. 반환값 혹은 그 외 필요한 객체들의 상태를 검사한다. 혹은 인터랙션을 검사하기도 한다.

toBlocking()은 데이터가 비동기로 전달되는 Rx의 특성상 그대로는 테스트하기가 힘들어서 Observable 시퀀스를 Blocking Observable로 변환해주는 RxBlocking의 기능 중 하나이다.


테스트 코드를 실행한 이후에 반환값을 검사하는 경우도 있지만, 행위 자체가 발생했는지 검사하는 경우도 많이 존재한다.

expect(result[0]).to(equal(movies))
verify(movieRepository, times(1)).getMovies()

verify(movieRepository, times(1)).getMovies()는 movieRepository의 getMovies 메서드가 의도대로 한 번 호출되었는지 확인하는 것이고 Cuckoo에서 제공하는 기능으로, 인터랙션이 정상적으로 일어났는지 확인하기 위해 사용한다.

테스트 코드를 작성했지만 아직 usecase의 메서드를 구현하지 않았기 때문에 테스트는 당연히 실패한다. 사실은 컴파일도 되지 않는다. 다시 GetMoviesUsecase로 돌아가서 메서드를 추가하자.

import RxSwift

public class GetMoviesUsecase {
  private let repository: MovieRepository

  public init(repository: MovieRepository) {
    self.repository = repository
  }

  public func execute() -> Observable<[Movie]> {
    repository.getMovies()
  }
}

execute 메서드를 추가하고 테스트가 정상적으로 통과하는지 확인한다. 앞으로 이렇게 TDD 원칙을 지켜가며 하나의 작은 프로젝트를 완성할 것이다.

Next.. 👋

모든 서비스에서 에러를 다루는 건 성공 케이스를 제어하는 것만큼이나 중요하다. 그렇기에 에러 처리를 위한 에러 타입들을 정의하고 에러를 핸들링하는 코드를 작성해보자. 그리고 아직 하나 남은 SetBookmarkUsecase를 추가할 것이다. 😉

전체 코드 Github


Written by@Heebeom Kim
iOS Engineer. Interested in Architecture, Automation. Work for CPNG

GitHubLinkedIn