티스토리 뷰

안녕하세요 Hani입니다. ☺️

 


잠깐 주저리주저리 해보자면..

 

얼마 전에 프로젝트를 하나 완성하고 Unit Test까지 해보는 경험을 가졌었는데

테스트 코드를 작성하는 것이 전부가 아니라

Testable한 코드를 짜야 테스트 코드를 잘 쓸 수 있다는 걸 알게 됐어요. 🥺

 

이 글을 쓰는 이유 역시 Testable한 코드를 짰다고 생각되지 않았기 때문..

 

 

크게는 두 가지 이유가 있었는데

 

첫번째는 저수준의 모듈은 고수준의 모듈에 직접 의존해도 된다고 생각했어요.

그런데 저저수준(?)의 모듈이 저수준 모듈에 의존하고

저수준 모듈이 고수준 모듈에 의존하다 보니

저저수준이 결국 고수준 모듈에 의존하는 추이 종속성 문제가 있었습니다.

(요건 클린 아키텍쳐 개방 폐쇄 원칙 부분 읽으면서 알게 됨..)

 

 

그래서 의존성 역전을 위해 고수준 모듈이 저수준 모듈에 의존하지 않도록 인터페이스를 두는 것뿐 아니라

저수준 모듈도 고수준 모듈에 직접 의존하지 않도록 인터페이스를 두는 게 맞는 방향인 것 같다는 생각이 들었습니다.

 

안 그러면 Target Membership에 체크할 게 엄청 많아짐.. 🥺

 

 

두 번째는 리턴 값이 없는 메서드는 테스트하기 힘들다는 것이었어요.

결과랑 예상한 값이 일치하는지 체크해야 하는데

리턴을 안 만드니까 -_-..  

 

테스트. 곤란.


 

 

암튼 WWDC17에서 발표된 Engineering for Testability를 통해 제가 느낀 게 맞는지 알아볼 거예요. 🥰

이번 주제에서는 Testable한 코드가 무엇인지 알고 App 코드를 Testable하게 개선하는 방법과

앱의 크기나 복잡성이 커져도 대응 가능한 확장성 있는 Test 코드를 작성하는 방법을 다루는데

 

이번 포스팅에서는 Testable App Code만 다룰 거예욥

 

먼저 Testable한 코드를 알아봅시당.

 

 

Unit Test는 input(Arrange), test(Act), output(Assert)으로 구성되어 있어요.

이를 AAA(Arrange-Act-Assert) 패턴이라구 합니당.

 

 

요로케 ☺️

 

 

Testable한 코드의 특성은 다음과 같아용.

 

입력을 제어할 수 있고, 출력을 검사할 수 있고

코드의 동작에 영향을 줄 수 있는 내부 상태에 의존하지 않는 것.

 

 

Testable한 코드를 작성하기 위한 두 가지 기술이 있는데 하나씩 살펴보겠습니다.

 

 

첫 번째 방법은 코드에 프로토콜과 매개변수화를 도입하는 거예욥. 👍

 

 

예시를 통해 알아봅시다.

미리보기 뷰가 있고 View나 Edit 모드를 선택할 수 있는 UISegmentControl이 있습니다.

Open 버튼을 누르면 모드에 따라 다른 앱으로 전환하는 동작을 할 거예요.

 

요로케

 

 

코드로 한 번 볼게욥.

Open버튼을 눌렀을 때 뭘 할 건지 구현한 코드입니다.

 

URL을 구성하는 비즈니스 로직이 있고,

UIKit에 의해 제공되는 싱글톤 UIApplication 인스턴스를 통해 URL에 해당하는 앱으로 전환할 거예요.

 

 

이 코드가 예상대로 동작하는지 알아보기 위해 UI Test를 작성해봅시다.

앱을 켜서 PreviewViewController로 이동한 다음 모드를 선택하고 open버튼을 눌러서 다른 앱이 잘 열리는지 확인..!

 

Arrange - Act - Assert에서 Act에 의한 Output으로 Assertion을 해야 하는데

UI Test는 앱을 전환하기 위해 위해 생성된 URL을 검사할 방법이 없습니다. 🥺

 

예를 들면 XCTAssertTrue(예상 URL == Output URL) 를 해야 하는데

url을 꺼내올 수가 없음 -_-..

 

 

이를 개선하기 위해 뭐가 문제인지 확인해봅시당.. 

첫 번째 문제는 뷰 컨트롤러에 메서드가 있다는 거예요.

테스트할 때 뷰 컨트롤러를 생성해줘야 했음..! (instantiateViewController)

 

두 번째 문제는 뷰(UISegmentControl)의 입력 상태를 직접 가져온다는 것입니다.

테스트에선 뷰의 속성에 대한 값을 지정해서 간접적으로 입력을 제공해야 했어요. (selectedSegmentIndex)

 

세 번째 문제는 UIApplication 인스턴스를 사용한다는 것입니다.

 

네 번째 문제는 canOpenURL 메서드의 bool 반환 값에 따라 openTapped 메서드의 동작이 바뀐다는 것..

사실상 openTapped의 Input이라고 할 수 있는데 마음대로 Input을 줄 수 없으니 제어하지 못하는 Input이라고 할 수 있어요.

 

다섯 번째는 open 메서드를 통해 앱을 열였을 때의 side effect에 관한 Unit Test를 작성할 수 없고

open은 앱을 background로 보내는데 다시 foreground로 가져올 방법이 없습니다.

 

 

아니 문제 엄청 많았네..ㅎ

 

이제 어떻게 바꿔야 Testable하게 변할지 지켜봅시다.

메서드가 뷰 컨트롤러에 있다는 문제를 해결하기 위해

DocumentOpener라는 클래스를 만들어서 openTapped에 있던 로직을 옮겼습니다.

 

이제 테스트하기 위해 뷰 컨트롤러를 생성할 필요가 없겠죠? ☺️

 

 

document와 모드에 대한 전달 인자도 직접 넣을 수 있습니다.

뷰의 상태를 가져올 필요가 없어요 👍

 

UIApplication 인스턴스는 DocumentOpener에서 직접 호출하지 않고 생성자를 통해 주입받습니다.

 

 

그럼 UIApplication.shared 대신 쓸 수 있죠? ☺️

 

 

Unit Test를 작성할 때 인스턴스를 만들어서 넘겨줘야 하는데..

 

UIApplication 객체는 우리가 제어할 수 없으니까

UIApplication를 서브 클래싱 하여 canOpenURL과 open을 오버라이딩한 클래스를 넣어주는 방법이 생각납니다.

 

하지만 UIApplication은 싱글톤이니까 인스턴스를 또 만드려고 하면 에러를 뱉죠 🥺 

 

 

UIApplication을 서브클래싱할 수 없으니 url에 관한 프로토콜을 만들어야 해요.

 

UIApplication이 URLOpening 프로토콜을 채택하면 따로 구현할 필요가 없습니다.

왜냐면 UIApplication에도 매개변수의 개수와 그 타입이 완벽히 일치하는 메서드들이 이미 구현되어 있기 때문이에요.

 

이를 메서드 시그니쳐가 동일하다고 표현합니다.

 

 

그럼 프로퍼티를 UIApplication이 아닌 URLOpening 프로토콜 타입으로 받을 수 있구

 

 

클래스에서 UIApplication에 관한 코드가 완전히 사라진 것을 볼 수 있습니다.

DocumentOpener의 생성인자는 URLOpening을 구현한 타입이면 전부 들어올 수 있어욥.

 

 

DocumentOpener 클래스의 생성인자로 UIApplication 객체를 넣기 싫으니까 (제어 불가능)

URLOpening 프로토콜을 준수하는 mock 객체를 만들어서 넣어줄 거예요.

 

 

테스트 코드를 다시 작성해봅시당

먼저 mock 객체를 만들어서 DocumentOpener 클래스의 생성인자로 넘겨 인스턴스를 생성합니다.

 

open 메서드에 Document와 Mode에 대한 인자를 넣어 테스트를 돌리고

 

예상 URL과 mock의 URL을 비교하여 검증합니다.

 

 

이번 예시를 통해서 UIApplication 싱글턴 인스턴스에 대한 직접 참조를 없애고 매개변수화된 Input으로 대체했습니다.

이를 바꿔 말하면 의존성 주입이에요. ☺️

 

또한 구체 클래스에 의존하기보단 프로토콜을 만들어서 인터페이스를 분리했어요.

테스트할 때 UIApplication 객체를 mock 객체로 대체할 수 있었습니다.

 

 

다음은 로직과 결과를 분리하는 것이 Testability를 어떻게 향상하는지 알아보겠습니당.

 

 

캐시에 저장할 아이템을 정의한 구조체와 저장된 아이템을 불러오는 프로퍼티가 있습니다.

 

 

여기선 캐시 저장 용량을 초과한 아이템을 비워주는 메서드를 테스트해볼 거예욥.

 

 

Input은 메서드의 매개변수에서 봤듯이 캐시의 저장 용량이에요.

저장 용량은 인자로 넣어줄 수 있으니까 제어할 수 있습니다.

 

그런데 캐시에 저장된 아이템을 불러오기 위해 FileManager를 사용하고 있어요.

FileManager를 사용한다는 것은 실제 File System에 대한 의존성을 가지고 있다는 문제가 있습니다.

 

또한 cleanCache 메서드는 리턴 값이 없었죠?

따라서 Output이 data가 아니에요.

오히려 FileManager에 의해 아이템이 삭제되는 side effect가 있습니다. 🥺

 

여기서 side effect란 메서드가 외부의 속성을 변경하는 것을 말해요.

예를 들면 메서드를 호출하는데 전역 변수가 변경된다든가.. 

 

 

따라서 cleanCache로는 정말 File System의 아이템이 clean되면 안 되고

어떤 것을 clean 할지 리턴 값으로 주는 게 낫다는 말씀.

 

 

이를 수정하기 위해 프로토콜과 매개변수화를 다시 도입할 수 있어요.

 

FileManager에 직접 의존하지 않으면서 아이템을 받으면 뭘 삭제할지 반환하는 구조로 만들어야 해요.

 

 

어떤 캐시 정책으로 아이템을 관리할지도 분리하면

직접 지우는 코드랑 뭘 지울지 결정하는 코드도 분리할 수 있습니다.

 

 

 

 

캐시에 있는 아이템을 받아서 뭘 삭제할지 반환하는 메서드를 프로토콜에 정의해둘게요.

 

 

지울 아이템을 담은 Set을 반환하도록 구현 ☺️

 

Policy 객체를 만들 때의 maxSize와  메서드를 실행할 때의 items 두 개의 Input을 받을 수 있어요.

 

 

Input와 Output이 명확하기 때문에 functional style을 만족할 수 있게 되었습니다. 🥰

 

 

이런 논리를 통해 Input을 정의하고 메서드를 호출하여 Output을 받아 예상 값과 일치하는지 확인하면 됩니다.

 

입력을 제어할 수 있고, 출력을 받을 수 있고, 그 외에 어떤 영향을 줄 만한 Hidden State가 없는 것.

Testable App Code의 조건을 만족시키고 있네요. ☺️

 

File System와 같은 느린 리소스에 의존하지 않아도 되기 때문에

더 빠른 테스트를 더 간결한 코드로 구현할 수 있습니다.

 

 

기존 cleanCache 메서드에 비해 깔끔해진 모습 🥰

 

CealnupPolicy 프로토콜을 채택한 구조체를 전달인자로 받고

해당 캐시 정책을 통해 어떤 아이템을 지울지 리턴받았습니다.

cleanCache에는 실제 데이터에 영향을 주는 side effect코드만 남게 되었어요.

 

 

정리해보면 side effect를 고려하여 비즈니스 로직과 알고리즘을 분리할 수 있었습니다.

 

여기서 캐시 정책 알고리즘은 Input과 Output을 설명하기 위해 값 타입을 사용하는 functional style을 취했어요.

 

cleanCache 메서드는 side effect를 일으키는 소량의 코드만 남도록 만들었습니다.

(여기서 side effect는 FileManager를 통해 외부에 있는 실제 데이터에 영향을 주는 것)

 

 

Testable한 코드를 작성하기 위한 방법을 알아봤습니다. ☺️

 

 


References

https://developer.apple.com/videos/play/wwdc2017/414/

 

댓글