먹고 기도하고 코딩하라

회사 통신 로직 테스트 코드 작성 본문

앱/Swift

회사 통신 로직 테스트 코드 작성

사과먹는사람 2024. 1. 20. 22:36
728x90
728x90

회사에서 사용하는 따로 통신 객체가 있다. 통신을 시도할 때마다 이 클래스의 객체를 생성하면, 생성 이후에 바로 네트워크 통신이 이뤄진다.

처음 입사했을 때 통신 처리 코드가 너무 복잡하고 어렵다고 생각했지만, 1년이 지나 다시 살펴보니 흐름을 쭉 따라갈 수 있었다.

 

테스트 코드 연습을 위해 통신 로직에 대한 테스트 코드를 짜고자 결심했다. 막상 테스트 코드를 짜려고 하니 다음과 같은 문제가 있었다.

  1. init 말미에 통신 요청 코드가 포함되어 있다. 즉, 생성과 요청이 분리되어 있지 않다.
  2. Rx 방식을 사용하지 않기에, 응답 데이터를 컴플리션 핸들러로 받게 된다.

또한 연습용 테스트 코드이기에 기존 코드 변경을 최소화하면서 테스트 코드를 짤 수 있도록 기반을 마련해야 했다. 이런 상황이라면 회사에서 사용하는 커스텀 통신 객체를 mocking해야 할까, 실제 통신부만 mocking해야 할까?

 

고민 끝에 지금 상황에서 비용을 줄이는 가장 좋은 방법은: 통신 객체를 만들되, 실제 요청을 하는 객체는 mock으로 우회하는 것이라는 생각이 들었다.

회사 코드의 통신은 Alamofire를 이용하고 있으니, Session 객체를 mocking하면 되겠다고 판단이 섰다. Alamofire 통신을 mocking하려면 실제로 통신을 요청하는 Session 객체를 목 객체로 바꿔주면 되기 때문에 좀 더 편리하게 테스트를 할 수 있다.

그래서 다음 조치를 취했다:

  • 통신 객체 생성자 매개변수에 session을 추가한다. 이 때, Session이 매개변수로 주어지지 않는다면 기본적으로 AF 객체를 쓴다. (프로덕션에서는 Alamofire 객체로 통신을 요청하게 될 것이다)
  • 실제로 요청하는 함수의 매개변수에도 session을 추가한다.
  • 요청 함수 내에서 실제로 .request를 쓰는 부분은 AF.request(…)로 되어 있는데, 이 AF를 session으로 변경해준다.
import Alamofire

class NetworkManager {
    func init(..., session: Session = AF) {
        request(session)
    }

    func request(_ session: Session) { 
        // AF.request(url, method, headers)
        session.request(url, method, headers)
    }
}

이렇게 하면 Session 객체를 주입해서 언제든지 바꿔쓸 수 있기 때문에 실제 요청을 하는 쪽에서는 테스트 준비 완료다.

 

그럼 이 Session은 어디서 만들까?

Session을 따로 만들어주기 전에, URLProtocol Mock 객체를 먼저 만들어줘야 한다.

Alamofire.Session은 URLSessionConfiguration을 매개변수로 받아 생성할 수 있는데, 이 URLSessionConfiguration의 protocolClasses로 [URLProtocol]을 받기 때문이다.

protocolClasses는 [AnyClass]? 타입으로, 세션에서 요청을 제어하는 프로토콜 서브클래스들을 담고 있는 배열이다.

 

막간을 이용해서 설명하자면…

URLProtocol은 해당 프로토콜에 특화된 URL 데이터 로딩을 제어하는 추상 클래스이고, URLSessionConfiguration은 URLSession 객체를 통해 데이터를 업로드/다운로드할 때의 행위와 사용되는 정책 등을 정의하는 객체다. 데이터를 업로드/다운로드할 때 configuration 객체를 먼저 만드는 것이 선행되어야 한다. 이 configuration으로 타임아웃 시간, 캐시 정책 등 URLSession 객체로 이루고자 하는 바들을 설정할 수가 있다.

Session 객체를 생성하기 전에 이 Configuration을 잘 설정해야 하는데, Session 객체에 한 번 configuration 설정이 되면 그 뒤로 Configuration 객체에 어떤 변화가 일어나도 무시되기 때문이다. 정책을 변경하려면 session configuration 객체 자체를 갈고 새로운 URLSession 객체를 사용해야 한다고 한다.

 

이 URLProtocol Mock 객체 잘 만들어두면 URLSession 통신도 mock하기 쉽다. (어차피 Alamofire도, URLSession도 configuration은 URLProtocol을 사용하기 때문이다)

 

목이 될 클래스를 하나 만들어서 URLProtocol 프로토콜을 상속하게 한다.

URLProtocol에 선언된 메소드들도 일부 오버라이드해야 한다.

final class MockURLProtocol: URLProtocol {
    // URLProtocol의 subclass가 특정 요청을 제어할 수 있는지 결정. 보통 true 
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    // 특정 요청의 canonical(표준?) 버전을 반환
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    // 요청이 시작될 때 할 일을 정리
    override func startLoading() {
        let response = setUpMockResponse()
        let data = setUpMockData()

        client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data!)
        client?.urlProtocolDidFinishLoading(self)
        // client?.urlProtocol(self, didFailWithError: error)
    }

    // 요청을 중단할 때 할 일을 정리
    override func stopLoading() { }
}

특히 startLoading 부분이 중요한데, 이 곳에서 데이터와 response를 주는 역할을 대신하기 때문이다. URLProtocol은 URLProtocolClient 프로퍼티를 갖고 있는데, 이 클라이언트에게 데이터가 있는지, 응답이 있는지 실패하는지 등을 알려주게 된다.

위 예시에서는 setupMockResponse, setupMockData라는 커스텀 함수에서 Response, Data를 받아와서 클라이언트에게 응답과 데이터가 있음을 전달하고 요청이 종료됨을 알린다.

이렇게 만든 URLProtocol은 Session을 만들 때 쓰인다.

import Alamofire

final class NetworkManagerTests: XCTestCase {
    var session: Session?

    override func setUp() {
        super.setUp()
        session = {
            let configuration: URLSessionConfiguration = {
                let configuration = URLSessionConfiguration.default
                configuration.protocolClasses = [MockURLProtocol.self]
                return configuration
            }()
            return Session(configuration: configuration)
        }()
    }
}

현재 통신 로직 실행이 생성과 결합되어 있다. 분리할 수도, 있지만 최대한 원본 코드를 지키면서 테스트하는 것이 최우선 목적이기에 다음과 같이 테스트를 작성할 수 있다.

struct User {
    var email: String?
}

final class NetworkManagerTests: XCTestCase {
    func testRequest() {
        let expectData = "my.email@gmail.com"
        let expectation = XCTestExpectation(description: "Performs a request")

        // code...

        NetworkManager<User>(complete: { data in
            guard let data else {
                XCTFail()
                return
            }
            // then
            XCTAssertEqual(data.email, expectData)
            expectation.fulfill()
        },
                                                    failed: { error, data in
            debugPrint(error?.localizedDescription)
            XCTFail("Failed for some reason.")
            return false
        },
                                                    session: session
        )

        wait(for: [expectation], timeout: 5)
    }
}

마찬가지로 URLSession 기반 통신을 하는 앱에서 통신 테스트를 하고 싶다면, URLSession.shared의 가짜 객체를 만들어주면 된다. URLSession을 이용한 통신 테스트는 WWDC 2018: Testing Tips & Tricks에서 자세히 설명하고 있으니 참고해도 좋겠다.

 

 

728x90
반응형
Comments