본문 바로가기
IOS App Programming/트러블 슈팅

클린 아키텍쳐 TestCode 분리하기

by B_Tori 2024. 8. 23.

문제 상황

프로젝트를 진행하면서 팀원들의 개발 일정 상 앱의 디자인을 어느 정도 진행 된 뒤 API 연결에 들어가게 되었다.
그러다 보니 연결이 제대로 되었는지, 데이터는 제대로 받아오는지 확인하기 위해 클린아키텍처 구조 상 DataSource (API 연결부)부터 Repository, UseCase를 지나 Presentation 레이어의 뷰모델, 뷰 까지 모두 연결되어야지만 테스팅이 가능했다.

그런데 이 때, 앱의 구현이 진행됨에 따라 프레젠테이션 레이어 연결 시 변경해줘야 하는 부분들이 점차 늘어났고, 테스팅 실패 시 최악의 경우 전체 구조를 변경해야 하는 경우도 있었다.

따라서 이 부분에서 불편함을 느껴 단계별로 DataSource 의 API 테스트, Repository의 DTO -> Entity 변환 테스트 및 UseCase의 비즈니스로직 테스트 등 분리하여 테스트 코드를 작성하고 단계별로 테스팅이 완료되면 프레젠테이션 레이어와 연결하도록 하였다.

 

클린아키텍처의 장점

  • 클린아키텍처의 구조는 각 계층이 서로 독립적으로 존재하며, 특히 핵심 비즈니스 로직이 외부 의존성(예: 데이터베이스, 네트워크 등)과 분리되어 있다.
  • 또한 인터페이스를 활용하여 의존성 역전 규칙을 지켜 상위 계층이 하위 계층에 의존하는 것이 아니라 인터페이스에 의존하고 있다.

이러한 구조 특성상 테스팅 시 다음과 같은 이점을 가지고 있다.

  • 비즈니스 로직이 외부 요소와 독립적이기 때문에, Mocking 또는 Stubbing을 사용하여 외부 의존성을 제거하고 순수한 단위 테스트를 작성할 수 있음
  • 상위 계층이 하위 계층에 의존하는 것이 아니라 인터페이스에 의존하기 때문에, 테스트 시 Mock 객체를 사용하기 용이 -> 프로토콜을 준수하는 목업 객체를 생성하여 테스트 가능
  • 의존성 주입을 이용해 연결되기 때문에, 테스트 코드에서 필요한 객체를 쉽게 교체할 수 있어, 테스트의 유연성이 증가

 

Testing 예시

실제 테스트를 진행한 API 중 일부이다.

DataSource Test

우선 첫번째로 DataSource만 분리하여 임시 데이터를 가지고 서버와 API 테스트를 진행하였다.

import XCTest
import RxSwift
import Alamofire
import RxAlamofire

@testable import CatchMate

final class FavoriteTest: XCTestCase {
    var disposeBag: DisposeBag!
    var dataSource: SetFavoriteDataSource!

    override func setUp() {
        super.setUp()
        disposeBag = DisposeBag()
        dataSource = SetFavoriteDataSourceImpl() // 실제 데이터 소스
    }

    override func tearDown() {
        disposeBag = nil
        super.tearDown()
    }

    func testFavprotePostAPI() {
        let expectation = self.expectation(description: "API Request")

        dataSource.setFavorite(true, "1")
            .subscribe(onNext: { result in
                print(result)
                XCTAssertFalse(!result.isEmpty, "API 호출 결과로 빈 목록이 반환됩니다.")
                expectation.fulfill()
            }, onError: { error in
                if let afError = error as? AFError {
                    switch afError {
                    case .invalidURL(let url):
                        print("Invalid URL: \(url)")
                    case .parameterEncodingFailed(let reason):
                        print("Parameter encoding failed: \(reason)")
                    case .multipartEncodingFailed(let reason):
                        print("Multipart encoding failed: \(reason)")
                    case .responseValidationFailed(let reason):
                        print("Response validation failed: \(reason)")
                        if let underlyingError = afError.underlyingError {
                            print("Underlying error: \(underlyingError)")
                        }
                    case .responseSerializationFailed(let reason):
                        print("Response serialization failed: \(reason)")
                    default:
                        print("AFError occurred: \(afError)")
                    }
                } else {
                    print("Other error occurred: \(error)")
                }
                XCTFail("API 호출이 실패했습니다: \(error)")
                expectation.fulfill()
            })

            .disposed(by: disposeBag)

        waitForExpectations(timeout: 5, handler: nil) // 비동기 실행을 위해 타임아웃을 걸어주어 비동기 흐름을 따라 원활하게 테스트할 수 있도록함
    }
}

이는 실제 데이터소스 객체를 사용하기 때문에 실제 서버와 API 요청을 수행한다.
실제 해당 테스트를 통해 데이터소스가 오류를 반환하는 사실을 알게 되었고, 디버깅 결과 스웨거에 나와있던 반환값과 실제 반환값 유형이 달라 스웨거를 따라 반환값을 설정하였기 때문에 오류가 났었다.

덕분에 쉽게 오류를 고칠 수 있었다.
이처럼 반환값이 다른 경우 모든 레어이어를 이미 연결해놨다면, 아까 말한 최악의 경우인 전체 레이어를 돌아가며 반환값에 따른 로직 수정에 들어가야 했는데 분리된 테스트를 통해 미리 발견한 덕에 쉽게 고칠 수 있었다.

Repository Test

Repository 테스트는 클린 아키텍쳐 장점인 목업 객체를 활용한 테스팅이 가능하다는 점을 사용하였다.
레포지토리 테스트에서 굳이 서버에 직접적인 요청을 할 필요는 없었다.

서버에서 API 요청을 하면 받아오는 객체의 구조를 따와 목업 데이터를 반환하는 목업 DataSource 객체를 만들어 해당 DataSource를 주입하였다.

이를 통해 외부 서버와 연결 없이도 Repository가 제대로 변환을 하고 있는지 확인할 수 있었다.


// 목업 데이터소스
class FavoriteListLoadDataSourceMock: LoadFavoriteListDataSource {
    func loadFavoriteList() -> RxSwift.Observable<[PostListDTO]> {
        // 실제 데이터소스는 API 연결 후 서버에서 데이터를 받아오지만 목업 데이터 소스는 별다른 외부 연결 과정 없이 목업데이터를 그대로 반환함
        return Observable.just(listLoaddataSourceMockUpData)
    }

    var listLoaddataSourceMockUpData: [PostListDTO] = [
        PostListDTO(boardId: 1, title: "잠실에 원터 보러 가실 분~", gameDate: "2024-08-12T18:00:00.000+00:00", location: "잠실", homeTeam: "두산", awayTeam: "한화", currentPerson: 2, maxPerson: 4),
        PostListDTO(boardId: 13, title: "야구 직관 모임", gameDate: "2024-08-12T18:00:00.000+00:00", location: "잠실", homeTeam: "두산", awayTeam: "한화", currentPerson: 3, maxPerson: 4)
    ]

}

final class FavoriteRepositoryTest: XCTestCase {
    var disposeBag: DisposeBag!
    var listLoadDataSource: LoadFavoriteListDataSource!
    var listLoadRepository: LoadFavoriteListRepository!
    var mockDataSource: FavoriteListLoadDataSourceMock!

    override func setUp() {
        super.setUp()
        disposeBag = DisposeBag()
        mockDataSource = FavoriteListLoadDataSourceMock()
        listLoadRepository = LoadFavoriteListRepositoryImpl(loadFavorioteListDS: mockDataSource)
    }

    override func tearDown() {
        disposeBag = nil
        super.tearDown()
    }

    func testFavoriteListMapping() {
        let expectation = self.expectation(description: "Repository Data Mapping Test")

        listLoadRepository.loadFavoriteList()
            .subscribe { result in
                print(result)
                // 3. 검증: 레포지토리가 데이터 소스에서 받은 데이터를 올바르게 처리했는지 확인
                XCTAssertEqual(result.count, 2, "목록에 두 개의 항목이 있어야 합니다.")
                XCTAssertEqual(result[0].id, "1", "첫 번째 게시물의 boardId가 일치해야 합니다.")
                XCTAssertEqual(result[1].title, "야구 직관 모임", "두 번째 게시물의 제목이 일치해야 합니다.")
                expectation.fulfill()
            } onError: { error in
                XCTFail("레포지토리 테스트 중 에러 발생: \(error)")
                expectation.fulfill()
            }
            .disposed(by: disposeBag)


        waitForExpectations(timeout: 5, handler: nil)
    }

    func testEmptyFavoriteListMapping() {
        let expectation = self.expectation(description: "Repository Empty Data Mapping Test")
        mockDataSource.listLoaddataSourceMockUpData = []
        listLoadRepository.loadFavoriteList()
            .subscribe { result in
                XCTAssertTrue(result.isEmpty, "목록이 비어 있어야 합니다.")
                expectation.fulfill()
            } onError: { error in
                XCTFail("레포지토리 테스트 중 에러 발생: \(error)")
                expectation.fulfill()
            }
            .disposed(by: disposeBag)
        waitForExpectations(timeout: 5, handler: nil)
    }
}

클린아키텍처에서 프로토콜 사용에 대해 어떤 점이 편리해서 이런 구조로 작성하는 걸까 처음에는 감이 잘 안 왔는데 테스트 코드를 작성하면서 클린아키텍처 구조의 장점을 제대로 느낄 수 있었던 거 같다.

댓글