Let's TDD (1)

alwaysblu·2021년 5월 5일
0
post-thumbnail

출처

(Suyeol Jeon)

TDD 보다 더 중요한 것

⭐⭐테스트 코드를 작성하는 능력이 TDD보다 더 중요하다.⭐⭐


Topics

  • Unit Test부터 연습하고 TDD를 숙련한다.

  • 외부 세계와의 접점에는 Mock을 활용한다.

  • Internal 구현체도 테스트에 활용할 수 있다.

  • 뷰 테스트는 상태에 따라 변하는 값을 테스트한다.

  • private 메서드는 만들기 전에 테스트되고 있어야 한다.

  • TDD로만 모든 코드를 작성하지는 않는다.

  • Cocoa Framework의 작동 방식을 다양하게 알아준다.

  • AppDelegate도 테스트 할 수 있다.


📌 개발한 앱에 발생할 수 있는 문제점

  • 의존성 주입이 안되서 모든 곳에 커플링이 걸려있다.

  • 커플링으로 인하여 네트워크를 분리하기 어렵다.

  • 뷰와 애니메이션이 많이 엮여있어서 어떻게 테스팅을 해야할지 모르겠다.


📌 네트워크 의존을 없애는 테스트 예시

Live Coding(GitHub Search)으로 설명한다.


< 완전히 잘못된 테스트 예시 >

기존에 작성해놓았던 RepositoryService 클래스의 search 메소드를 테스트하는 잘못된 코드이다.

search는 네트워크 API를 호출하기 위한 것이므로
비동기로 테스트하기 위해서
XCT에서 기본적으로 제공하는 XCTestExpectation() 라는 클래스를 사용한다.


잘못된 테스트인 이유 :

만약 wifi와 같은 네트워크를 사용할 수 없는 상황에서
위와 같은 테스트를 돌린다면 테스트가 실패하게 된다.
왜냐하면 위의 테스트 코드는 절대적으로 네트워크에 의존하고 있기 때문이다.


실제 프로덕션 앱에서 사용하는 네트워크 매니저와 테스트에서 사용하는 네트워크 매니저를 구분한다.

  1. 실제 GitHub Search 앱을 사용할 때의 Network Manager
    진짜로 Alamofire가 제공하는 Session Manager를 사용할 것이다.

  2. 테스트 환경에서의 Network Manager
    진짜로 Alamofire가 제공하는 Session Manager의 역할을 흉내만 내는 Mock Object를 사용할 것이다. Mock Object를 사용하기 위해서는 의존성을 주입을 시켜줘야한다.


< 네트워크에 의존하지 않는 테스트를 만드는 단계 >

  1. 아래의 코드를 삭제한다.
let expectation = XCTestExpextation()
XCTWaiter().wait(for: [expextation], timeout: 10)
  1. 삭제한 위치에 아래의 코드를 작성한다. (이유 생각해서 아래 부연 설명을 할 것)
var sessionManagerStub = SessionManagerStub()
let service = RepositoryService(
	sessionManager: sessionManagerStub
) // RepositoryService를 만들 때 sessionManagerStub를 주입을 시켜주기 위한 코드이다.

RepositoryService가 search 메소드를 정의할 때 class 메소드로 정의했는데 이 부분을 바꿀 것이다.

  1. 가장 많은 네트워크의 의존성을 갖고 있는 코드는 아래 이미지의 request 함수이다.

SessionManager는 Alamofire에서 제공하는 open class이다.
빨간색 박스의 코드의 의미는 SessionManager의 싱글톤 인스턴스를 얻어와서
request를 날린다는 의미이다.
이 빨간 박스 부분이 강하게 커플링이 되어있다.

커플링이란 서로 상호작용하는 시스템들(객체)간의 의존성을 의미한다.

  1. 일단 작동하고 있는 search 메소드 코드가 있으므로 이 작동하는 코드를 복사해서 붙여넣어 2개의 search 메소드를 만든다.
    (테스트할 메소드 : A search 메소드, 기존의 메소드 : B search 메소드라고 하자)

  2. A search 메소드에 @available(*, deprecated)를 달아둔다.

@available(*, deprecated) : 이 코드의 의미는 사용하면 안되는 코드라고 하는 것 같다. 조사해보자.

  1. B search 메소드에서 class를 제거해서 인스턴스 메소드로 만든다.

  2. B search 메소드 안의 SessionManager.default로 싱글톤 인스턴스를 얻어오는 대신에 self에 있는 sessionManager를 얻어오도록 아래와 같이 코드를 변경한다.

self.sessionManager.request(url,method: .get, parameters: parameters, encoding: URLEncoding(), headers: nil)
  1. self에 sessionManager를 아래 이미지와 같이 선언하고 init으로 초기화한다.

    현재까지 변경된 사항만으로는 모든 Network Manager의 기능을 Mocking할 수 없다.
    왜냐하면 정확히는 Session Manager의 인스턴스를 주입을 시켜줬지만
    (self.sessionManager.request() 코드를 통하여)
    request 안의 인자를 가짜 데이터로 채워줘야하기 때문에
    SessionManager와 똑같은 인터페이스를 가진 프로토콜을 만들어줄 것이다.
  1. 아래 이미지와 같이 SessionManagerProtocol.swift 파일을 만든다.

  2. SessionManagerProtocol.swift 파일의 프로토콜을 정의하는데 이때 이 프로토콜에는 self.sessionManager.request(url,method: .get, parameters: parameters, encoding: URLEncoding(), headers: nil)와 같이 sessionManger에 있는 request와 같은 인터페이스가 되어야한다. 아래 이미지와 같다.

  1. SessionManagerProtocol 프로토콜을 아래와 같이 request 인터페이스와 동일하게 만들어준다. 대신에 프로토콜에서는 default 파라미터가 허용이 되지 않기 때문에 default 파라미터는 모두 제거해줘야한다. 즉, 파라미터를 초기화 시키지 않는다는 의미다. 아래 이미지와 같다.

  2. 이미 있는 SessionManager 클래스에 SessionManagerProtocol 프로토콜을 extension을 이용하여 적용시킬 것이다.

extension SessionManager: SessionManagerProtocaol {
}

이때 SessionManager는 이미 SessionManagerProtocol에 있는 메소드를 모두 구현하고 있으므로 따로 SessionManager 클래스에 구현할 필요가 없다.

  1. 의존성을 주입받을 때 SessionManager와 같은 클래스 그 자체를 주입받는 대신에 한번 간접화를 시킨 SessionManagerProtocol을 주입 받도록 코드를 변경한다.


위의 이미지에서 아래 이미지와 같이 코드를 변경한다.

  1. 이제 SessionManagerProtocol을 따르는 가짜 객체를 만들어서 아래 빨간 네모 박스에 넣어 줄 수 있게 되었다.

  1. 위의 이미지의 빨간 네모 박스에 가짜 객체를 넣기 위하여 SessionManagerProtocol을 따르는 가짜 객체 SessionManagerStub을 만든다.
    (아래 이미지에서는 코드를 위에 작성하였지만 따로 가짜 객체를 위한 파일을 만들어서 그 파일에 코드를 작성해야한다.)

가짜 객체를 만들고 해당 객체에 원하는 응답을 내려줄 수 있도록 SessionManagerStub을 만들 것이다.
이 SessionManagerStub에서는 이 request에 어떤 url이 들어왔고 어떤 파라미터가 들어왔고 어떤 값들이 들어왔는지를 테스트할 것이다.

왜냐하면 Alamofire에서 SessionManager의 request메소드를 호출할 때
정상적인 url이랑 정상적인 파라미터만 넣으면 알아서 네트워킹이 잘될 것이라는 것을 우리는 잘 알고 있기 때문에 해당 테스트를 시행한다.
그렇기 때문에 그것을 가정을 하고 테스트를 먼저 작성한다.
아래 이미지와 같이 requestParameters라는 튜플을 만들어서
request 메서드가 호출되었을 때 아래 코드와 같이 멤버 변수로 저장을 시켜주도록 한다.

self.requestParameters = (
	url: url,
    method: method,
    parameters: parameters
)

이 가짜 객체인 SessionManagerStub의 request가 실행이 되면
이 SessionManagerStub가 가지고 있는
requestParameters라는 옵셔널 튜플 안에
가장 최근에 불러졌던 request 함수의 파라미터들이 저장(기록)이 되게 된다.

따라서 우리는 이 저장된 파라미터가 정확한 값(url, method, parameters)이 들어가 있는지를 테스트하기만 하면 네트워크에 의존되지 않은 테스트 코드가 된다.

  1. service.search(ketword:"Let's Swift") 를 작성한다. (그냥 아무거나 작성한 것)

  2. sessionManager의 requestParameters를 아래 코드와 같이 테스트를 통해 검증을 한다.

//when
service.search(keyword: "Let's Swift", completionHandler: { _ in  })

//then
let params = sessinManager.requestParameters
XCTAssertEqual(try params?url.asURL().absoluteString, "https://api.github.com/search/repositories")
XCTAssertEqual(params?.url?, HTTPMethod.get)
XCTAssertEqual(params?.parameters as? [String : String], ["q":"Let's Swift"]) // params?.parameters는 Any 타입인데 Any는 == 비교가 안되므로 [String:String]으로 타입 캐스팅을 해서 비교해준다.
  1. 가짜 객체인 SessionManagerStub의 함수인 request 함수에서
    DataRequest를 return 해주도록 코드를 작성한다.
    이때 DataRequest는 public하게 생성된 init 생성자 인터페이스가 없다.
    (그냥 DataRequest는 init이 없다는 뜻이다.)
    따라서 import Alamofire를 @testable import Alamofire로 변경해주면
    DataRequest에서 init을 사용할 수 있다.

    (?? @testable과 init의 연관성에 대해서 조사할 것)

return DataRequest(session: URLSession(), requestTask:.data(nil, nil))
  1. 여기까지 하면 search 메소드에 대한 Test는 네트워크 의존성이 없는 테스트 코드로 완성이 된다.

  2. 이 테스트가 잘짜여진 테스트인지를 확인하려면 기존에 짜있던 RepositoryService final class의 search 메소드의 인자 값으로 아래 이미지와 같이 url을 잘못 넣거나 파라미터를 잘못 넣는다면 테스트를 실패해야 한다.

이렇게 잘못된 인자 값을 넣은 테스트가 실패한다면 잘짜여진 테스트인 것을 확인할 수 있다.


레거시 코드 처리 (기존의 search 메소드)

아직 final class인 repositoryService에

class 메소드로 만들어서

@available(*, deprecated)를 달아놓은 (기존의) B search 메소드가 레거시 코드로 존재한다.

이 레거시 코드를 처리해보자.

레거시 코드라는 말은 더 이상 쓰기 힘들거나 화나게 만드는 코드를 일컫는다.
화나게 만드는 코드라... 부정적인 표현의 용어임에 틀림없다.
이런 코드는 아래와 같은 경우에 해당한다.

  • 다른 코드와의 개연성을 무시한 채 Due Date만 맞춰 작성한 코드
  • 코드의 종속성... 디펜던시를 낮추는 노력이 1도 없는 코드
  • 코맨트 등을 전혀 남기지 않아 더 수정, 보완 등이 어려운 코드
  • 기능 단위의 함수 나아가 모듈 자체가 지나치게 큰 코드

(테스트를 위해서 만든) A search 메소드는 테스트 시에는 잘 작동을 하는데 뷰 컨트롤러에서는 잘 작동하지 않을 수도 있다.

B search 메서드 대신에 이 A search 메소드를 뷰 컨트롤러에서 잘 작동하도록 할 것이다.

그러기위해서는 테스트를 작성했을 때와 마찬가지로

final class인 RepositoryService를 뷰컨트롤러에 주입을 시켜줄 것이다.

  1. 뷰 컨트롤러 클래스에 RepositoryService 객체를 프로퍼티로 만들어준다.

  1. AppDelegate.swift의 func application(application:, launchOptions:)에 rootViewController를 찾아서 넣어준다.


rootViewController를 찾기 위한 함수를 Appdelegate.swift 파일의 func application(application:, launchOptions:)에서 따로 추출함

  1. repositoryService를 주입시키기 위해서 RepositoryService 객체를 하나 만든다.
    RepositoryService 객체를 만들 때 인자로 SessionManager를 주입 받도록 했다.
    하지만 이를 위해 SessionManager를 만들 필요가 없다 왜냐하면 이미 Alamofire에서 싱글톤 오브젝트로 제공을 하고 있기 때문이다.

  2. 2번에서 찾은 rootViewController에 3번에서 만든 RepositoryService 객체를 주입을 시켜준다.

  1. 여기까지 하면 이제 viewCotrollerd에서 (기존의) B search class 메서드 대신에
    주입받은 RepositoryService 객체의 search 메소드를 사용할 수 있다.

위의 이미지에서 아래 이미지와 같이 코드를 변경했다.

  1. 이제 class 메소드로 만들어서 @available(*, deprecated)를 달아놓은 (기존의) B search 메소드가 레거시 코드를 삭제할 수 있다.

Tip


만약 SearchRepositoryViewController에서 sessionManager.default를 갖고 있게 되면
이 SearchRepositoryViewController를 테스트할 때 또 네트워크 의존성이 생겨버리기 때문에
sessionManager.default를 AppDelegate에서 갖고 있게 하고
AppDelegate로 부터 rootViewController인 SearchRepositoryViewController에
sessionManager.default를 주입시키므로서
미리 의존성을 분리를 시켜버리는 것이다.

📌 결론

위에서 실행한 테스트는

기존에 만들어진 메서드에서 네트워크나

다른 여러 side effect에 강하게 커플링이 되어있는 객체를 테스트를 할 때

외부에서 가짜 데이터를 제공하는 어떤 stub 혹은 Mock 객체를 넣고

그 기존에 만들어진 메서드의 동작을 테스트를 하는 테스트의 기법이다.


📌 이 테스트 기법으로 예방할 수 있는 예시 사례

  • 주니어 개발자가 다른 API를 이용했을 때 잘못된 응답이 오거나 잘못된 요청이 가는 것을 예방할 수 있다.









다음 편에서 계속..



















0개의 댓글