RxCleanSwift icon indicating copy to clipboard operation
RxCleanSwift copied to clipboard

Clean Swift 연구일지 #4

Open GeekTree0101 opened this issue 5 years ago • 0 comments

Template 만들고 Unit Test 하는 도중 파악된 문제점

https://github.com/GeekTree0101/RxCleanSwift-Template 위의 링크와 같이 현재 RxCleanSwift Template를 만들다가 발생한 이슈에 대해서 예기하고자 한다.

Interactor Unit Test

아래와 같이 UserInteractor를 만들었다 가정하자.

UserInteractor.swift


protocol UserInteractorInputLogic {

    var input: PublishRelay<UserModels.Model.Request> { get }
}

protocol UserInteractorOutputLogic {

    var output: Observable<UserModels.Model.Response> { get }
}

protocol UserDataStore {

    var title: String { get set }
}

class UserInteractor: UserInteractorInputLogic & UserDataStore {
    
    // MARK: Input Properties
    var input: PublishRelay<UserModels.Model.Request> = .init()

    // MARK: DataStore
    var title: String = ""
  
    // MARK: Workers
    var worker: UserWorker
    
    init(worker: UserWorker) {
        self.worker = worker
    }
}

extension UserInteractor: UserInteractorOutputLogic {
    
    var output: Observable<UserModels.Model.Response> {
        return self.input
            .flatMap({ [unowned self] request -> Observable<Int> in
                return self.worker.mul(request.input)
            })
            .map { num in
                return .init(output: num)
        }
    }
}

위와 같이 Interactor의 Input, Ouput Logic을 protocol로 설계후 worker를 initialize해서 만들었고 이를 Test하고자 한다.

UserInteractorTests.swift (수정전)

class UserInteractorTests: XCTestCase {
    
    var interactor: UserInteractor!
    var disposeBag = DisposeBag()
    
    override func setUp() {
        self.disposeBag = DisposeBag()
        self.interactor = UserInteractor.init(worker: ???)
    }
    
    func testUseScheduler() {
        let scheduler = TestScheduler.init(initialClock: 0)
        
        // Given
        scheduler.createColdObservable(
            [.next(100, UserModels.Model.Request(input: 100)),
             .next(200, UserModels.Model.Request(input: 200))
            ])
            .bind(to: interactor.input)
            .disposed(by: disposeBag)
        
        let outputEvents = scheduler.createObserver(Int.self)
        
        // When
        interactor.output
            .map({ $0.output })
            .bind(to: outputEvents)
            .disposed(by: disposeBag)
        
        scheduler.start()
        
        
        // Then
        XCTAssertEqual(outputEvents.events,
                       [.next(100, 10000),
                        .next(200, 40000)])
        
    }
}

문제는 Worker인데 이유는 다음과 같다. Clean Swift의 Worker는 비즈니스로직만 다루며 CoreData 및 Network를 이용하여 필요한 값을 반환하는데 직접적으로 Worker를 사용해 필요한 값을 빼내건 좋지않기 때문에 Stub 또는 Spy 형태로 만들어서 사용한다.

  • Stub: Use a stub to control the input to the system under test so you can test how the behaviour changes according to it.
  • Spy: Use a spy to record the output or effect produced by system under test on the double so you can verify it behaves as you'd expect.

input에 따른 output값의 변화가 필요하기에 Stub을 사용하는 것이 바람직하다.

또한 input과 output에 대한 접근만 필요하기 때문에 interactor의 Type은 UserInteractor 보단 (UserInteractorInputLogic & UserInteractorOutputLogic) 가 바람직하다.

UserWorkerStub.swift

class UserWorkerStub: UserWorker {
    
    override func mul(_ input: Int) -> Observable<Int> {
        return Observable.just(input * input)
    }
}

UserInteractorTests.swift (수정후)

class UserInteractorTests: XCTestCase {
    
    var interactor: (UserInteractorInputLogic & UserInteractorOutputLogic)!
    var disposeBag = DisposeBag()
    
    override func setUp() {
        self.disposeBag = DisposeBag()
        self.interactor = UserInteractor.init(worker: UserWorkerStub.init())
    }
    
    func testUseScheduler() {
        let scheduler = TestScheduler.init(initialClock: 0)
        
        // Given
        scheduler.createColdObservable(
            [.next(100, UserModels.Model.Request(input: 100)),
             .next(200, UserModels.Model.Request(input: 200))
            ])
            .bind(to: interactor.input)
            .disposed(by: disposeBag)
        
        let outputEvents = scheduler.createObserver(Int.self)
        
        // When
        interactor.output
            .map({ $0.output })
            .bind(to: outputEvents)
            .disposed(by: disposeBag)
        
        scheduler.start()
        
        
        // Then
        XCTAssertEqual(outputEvents.events,
                       [.next(100, 10000),
                        .next(200, 40000)])
        
    }
    
    func testUseRxBDD() {
        RxBDD.init(inputObservable: interactor.input,
                   outputType: Int.self)
            .given([
                .next(100, UserModels.Model.Request(input: 10)),
                .next(200, UserModels.Model.Request(input: 20))
                ])
            .when(interactor.output.map({ $0.output }))
            .then({ outputs in
                XCTAssertEqual(outputs, [.next(100, 100), .next(200, 400)])
            })
    }
}

class UserWorkerStub: UserWorker {
    
    override func mul(_ input: Int) -> Observable<Int> {
        return Observable.just(input * input)
    }
}

요약

  • Worker Stub 활용
  • Interactor의 Input, output만 고려하자

Presenter Unit Test

문제는 RxCleanSwift-Template의 Presenter부분이다. Template를 이용해 Presenter를 만들면 다음과 같이 형성된다.

UserPresenter.swift (수정전)


protocol UserPresenterInputLogic {
    
    var input: PublishSubject<UserModels.Model.Response> { get }
}

protocol UserPresenterOutputLogic {
    
    var output: Driver<UserModels.Model.ViewModel> { get }
}

class UserPresenter: UserPresenterInputLogic {
    
    var input: PublishSubject<UserModels.Model.Response> = .init()

    private let disposeBag = DisposeBag()

    init(interactor: UserInteractorOutputLogic) {
        self.binding(interactor)
    }

    private func binding(_ interactor:  UserInteractorOutputLogic) {
        interactor.output
            .bind(to: input)
            .disposed(by: disposeBag)
    }
}

extension UserPresenter: UserPresenterOutputLogic {
    
    var output: Driver<UserModels.Model.ViewModel> {
        return input.map { UserModels.Model.ViewModel.init(title: "title-\($0.output)") }
            .asDriver(onErrorJustReturn: UserModels.Model.ViewModel.init(title: "unknwon"))
    }
}

위와 같은 형태라면 테스트할 때 UserInteractorOutputLogic(Interactor의 Ouput)을 Stub형태로 만들어 처리해야하는데 Clean Swift에 의하면 V(ViewController), I(Interactor), P(Presenter)는 서로서로 알 필요없이 독립적으로 Unit Test가 가능하다에 위반한다는 사실 ㅠㅠ

Presenter역시 Interactor의 테스트와 마찬가지로 Input(Response)과 Output(ViewModel)만 테스트해주면 되기 때문에 initialization에서 Interactor의 OutputLogic을 init에서 처리할 필요가 없다. 하지만, interactor output logic의 binding을 해줘야하기 때문에 binding method는 public으로 수정한다. 또한, binding보단 단방향성을 더 강조하기 위해 bind(from:) 이 더 이쁠꺼 같아서 bind(from:)으로 수정합니다.

UserPresenter.swift (수정후)


protocol UserPresenterInputLogic {
    
    var input: PublishSubject<UserModels.Model.Response> { get }
}

protocol UserPresenterOutputLogic {
    
    var output: Driver<UserModels.Model.ViewModel> { get }
}

class UserPresenter: UserPresenterInputLogic {
    
    var input: PublishSubject<UserModels.Model.Response> = .init()

    private let disposeBag = DisposeBag()

    public func bind(from interactor:  UserInteractorOutputLogic) {
        interactor.output
            .bind(to: input)
            .disposed(by: disposeBag)
    }
}

extension UserPresenter: UserPresenterOutputLogic {
    
    var output: Driver<UserModels.Model.ViewModel> {
        return input.map { UserModels.Model.ViewModel.init(title: "title-\($0.output)") }
            .asDriver(onErrorJustReturn: UserModels.Model.ViewModel.init(title: "unknwon"))
    }
}

위의 Presenter에 대한 Test Code를 작성하면 다음과 같습니다.

UserPresenterTests.swift


class UserPresenterTests: XCTestCase {
    
    var presenter: (UserPresenterInputLogic & UserPresenterOutputLogic)!
    var disposeBag: DisposeBag!
    
    override func setUp() {
        disposeBag = DisposeBag()
        presenter = UserPresenter.init()
    }

    func testPresenter() {
        
        func testPresenter() {
            let scheduler = TestScheduler.init(initialClock: 0)
            
            // Given
            scheduler.createColdObservable(
                [.next(100, UserModels.Model.Response.init(output: 100)),
                 .next(200, UserModels.Model.Response.init(output: 200)),
                 .next(300, UserModels.Model.Response.init(output: 300))
                ])
                .bind(to: presenter.input)
                .disposed(by: disposeBag)
            
            let outputEvents = scheduler.createObserver(String.self)
            
            // When
            presenter.output
                .map({ $0.title })
                .drive(outputEvents)
                .disposed(by: disposeBag)
            
            scheduler.start()
            
            // Then
            XCTAssertEqual(outputEvents.events,
                           [.next(100, "title-100"),
                            .next(200, "title-200"),
                            .next(300, "title-300")])
            
        }
    }
}

정리요약

  • Presenter의 Input, Output Logic만 고려하자.

image

GeekTree0101 avatar May 16 '19 09:05 GeekTree0101