유닛 테스트 파트 2 - 유닛 테스트 모노비헤이비어

이전 블로그 포스트 유닛 테스트 파트 1 - 책으로 배우는 유닛 테스트에서 약속한 대로, 이번 글에서는 테스트 가능성을 염두에 두고 모노비헤이비어를 디자인하는 데 집중합니다. 모노비헤이비어는 유니티에서 특별한 방식으로 처리하는 일종의 특별 클래스입니다. 모노비헤이비어 파생상품을 인스턴스화하려고 할 때마다 허용되지 않는다는 경고가 표시됩니다. 착한 보이스카우트이고 경고를 무시하지 않는다면(경고를 무시하는 것은 장기적으로 좋지 않습니다!) 어떻게 하면 모노비헤이비어를 조롱할 수 있을까라는 질문을 스스로에게 던져보았을 것입니다. 좋은 소식은 그럴 필요가 없다는 것입니다! 소개해드리겠습니다...
이미 테스트를 작성해 본 적이 있다면 UI, 레거시 코드, 소스 코드에 액세스할 수 없는 잘못된 디자인 또는 높은 수준의 동시성이 있는 영역과 같은 단위 테스트의 천적에 부딪힌 경험이 있을 것입니다. 이러한 부품을 테스트하기 어려운 이유는 무엇인가요? 격리 달성: 테스트 대상과 컨텍스트를 분리합니다. 레거시 코드에는 도움이 되는 도구가 있지만 새 코드에는 매우 간단한 패턴을 사용할 수 있습니다: 겸손한 개체 패턴.
이 패턴의 아이디어는 매우 간단합니다. 테스트하기 어려운 종속성이 있는 컴포넌트를 테스트하려면 컴포넌트의 모든 로직을 별도의 분리된(따라서 테스트 가능한) 클래스로 추출한 다음 이를 참조하세요. 다시 말해, 문제가 있는 컴포넌트(테스트 작성자의 삶을 비참하게 만드는 종속성이 있는)는 모든 논리 연산이 새로 생성된 클래스에 위임되어 논리 코드가 최대한 적은 매우 얇은 코드 레이어가 됩니다.
테스트에 간접 종속성이 있는 상태에서 테스트할 수 없는 구성 요소로...

...테스트가 나쁜(아니, 테스트할 수 없는) 코드를 인식하지 못하는 상태까지 도달했습니다:
여기까지입니다. 솔직히 말해서 당연한 일입니다.
코드와 테스트 가능성 측면에서 게임이 특별한 이유는 무엇일까요? 게임 테스트는 다른 소프트웨어 테스트와 어떻게 다른가요? 개인적으로 저는 게임을 꽤 정교한 소프트웨어라고 생각합니다. 게임이 매일 사용하는 소프트웨어와 크게 다르지 않다고 말하는 것은 순진한 생각일 수 있습니다. 게임에서는 (물론 예외는 있지만) 반짝이고 세련된 그래픽, 배경 음악 및 기타 잘 설계된 사운드 샘플을 찾을 수 있습니다. 게임은 다양한 출력 장치(읽기 해상도)뿐만 아니라 다양한 소스로부터의 실시간 입력을 처리해야 하는 경우가 많습니다. 기능 외 요구 사항도 게임의 경우 더 엄격할 수 있습니다. 멀티플레이어 게임에서는 안정적이고 동기화된 네트워크 연결이 필요하며, 동시에 일정한 프레임 속도를 유지하는 데 필요한 성능을 유지해야 합니다.
따라서 다양한 종류의 미디어와 기술을 다루는 복잡한 시스템이 될 수 있습니다. 저에게 게임은 항상 소프트웨어 최종 결과물로서 걸작이었고, 그 중 일부는 예술 작품으로 인정받기를 열망했습니다(고전적인 시각적 측면뿐만 아니라 기술적인 비하인드 스토리 측면에서도요).
이 모든 복잡성은 코드 아키텍처에 영향을 미칩니다. 안타깝게도 고성능 아키텍처는 일반적으로 좋은 코드 설계에 불리하게 작용하며, Unity에서도 이러한 제약이 발생할 수 있습니다. 특별한 방식으로 설계되어야 했던 핵심 메커니즘 중 하나가 바로 모노비헤이비어스 메커니즘입니다. (상식적으로 짐작할 수 있듯이) MonoBehaviours의 콜백이 인터페이스나 상속으로 구현되지 않은 이유가 궁금하다면 성능상의 이유 때문입니다(댓글에서 Lucas Meijer의 설명을 참조하세요). 자세히 설명하지 않더라도 이는 모노비헤이비어의 테스트 가능성에도 불리하게 작용합니다. 새 연산자로 모노비헤이비어를 인스턴스화할 수 없다는 사실은 모방 프레임워크를 사용하는 것을 거의 금지합니다. 모노비헤이비어를 사용할 때마다 뒤에서 일어나는 모든 일들을 고려하면 좋은 생각이 아닐 수도 있습니다. 이 동작을 가로채면 많은 문제가 발생할 수 있습니다.
결국 모든 것은 테스트 가능한 코드를 작성하는 데 얼마나 동기를 부여하느냐에 달려 있습니다. 많은 접근 방식이 동일한 문제를 해결할 수 있지만 테스트 자동화에 적합한 것은 극소수에 불과합니다. 테스트 가능한 코드를 작성하려면 때때로 생각보다 많은 코드를 작성해야 할 때가 있습니다. 아직 학습 중이거나(어차피 평생 학습해야 하지 않나요?) 테스트 자동화 모험의 길에 막 들어선 경우, 일부 코드 조각이나 설계 가정이 불필요한 오버헤드로 느껴질 수 있습니다. 그러나 이러한 작업은 금방 습관이 되어버리기 때문에 생각하지도 못한 채 프로 자동화 디자인을 사용하기 시작하면 눈치 채지 못할 것입니다.
이 블로그 게시물에서는 나중에 테스트할 수 있도록 모노비헤이비어를 디자인하는 방법을 보여드리겠다고 약속드렸습니다. 모노비헤이비어 자체를 테스트하지는 않을 것이기 때문에 완전히 사실이라고 할 수는 없습니다. 험블 객체 패턴을 디자인에 구현하여 테스트 가능성을 높이는 방법에 대해서는 이미 알고 계시겠지만, 실제 프로젝트에서 구현된 아이디어를 보여드리겠습니다.
이 예제의 목적에 맞는 사용 사례를 만들어 보겠습니다. 우주선을 조종하는 간단한 플레이어 컨트롤러를 상상해 보세요. 예제를 단순화하기 위해 2D 월드스페이스에 적용해 보겠습니다. 우리는 우주선이 사방으로 날아다닐 수 있기를 바랍니다. 총알(우주 로켓?)을 똑바로 쏠 수 있지만 주어진 발사 속도보다 더 자주 쏠 수는 없는 총이 있습니다. 총알 개수도 총알 홀더의 용량에 따라 제한되므로 총알을 모두 쏘고 나면 재장전해야 합니다. 더 재미있게 만들기 위해 우주선의 체력에 따라 이동 속도가 달라지도록 만들어 봅시다.
우주선의 컨트롤러 역할을 할 모노비헤이비어는 다음과 같은 모습일 수 있습니다:

FixedUpdate 콜백에서는 입력을 읽고 사용자가 누른 버튼에 따라 작업을 수행합니다. 우주선 주위를 이동하려면 축의 방향에 따라 일정한 속도로 우주선의 위치를 변환해야 합니다. 코드에서 볼 수 있듯이 델타X 및 델타Y 변수는 곱셈입니다: 입력 축의 값과 상태 수준에 따라 달라지는 속도 상수인 Time.fixedDeltaTime을 사용합니다.
발사1 이벤트(예: 마우스 왼쪽 버튼 클릭)에서 총알을 발사할 수 있는지 확인하려고 합니다. 우선 총알 홀더에 총알이 하나 이상 남아있어야 합니다. 둘째, 우주선이 특정 속도(이 경우 0.5초에 한 번)로만 촬영할 수 있도록 하려고 합니다. 따라서 마지막 총알이 발사된 후 얼마나 시간이 지났는지 확인합니다. 준비가 완료되면 총알을 생성합니다.
Fire2 이벤트는 단순히 총알 홀더를 재장전합니다.
이 로직에 대한 단위 테스트를 작성하려면 두 가지 문제를 극복해야 합니다. 첫 번째는 앞서 언급했듯이 상속을 통해 의존하는 모의할 수 없는 MonoBehaviour 클래스입니다. 두 번째 문제는 실시간 소프트웨어의 경우 더 일반적인 문제입니다. 우리의 로직은 시간(발동 속도)에 의존하기 때문에 Unity에서 정적 시간 클래스를 가로챌 수 없기 때문에 단위 테스트를 수행할 수 없습니다. 좋은 소식은 이 모든 문제를 해결할 수 있다는 것입니다.
간단한 메서드 추출 리팩터화를 적용하고 로직 메서드가 Unity API(이 경우 입력 처리 및 불릿 인스턴스화)를 참조해서는 안 된다는 점을 염두에 두고 코드를 약간 리팩터링해 보겠습니다. if 문에 있는 시간 종속성도 별도의 메서드로 추출해야 합니다. 최종 결과는 다음과 같은 모습일 것입니다:

보시다시피, 여기서 FixedUpdate 메서드는 사용자의 입력을 로직 부분을 수행하는 메서드에 전달하는 것 이상을 수행하지 않습니다. 발사율 검사는 지정된 시간이 지나면 'true' 결과를 생성하는 CanFire 메서드로 추출되었습니다. 이 추출은 나중에 단위 테스트를 작성할 수 있으므로 중요합니다. 지금 바로 SpaceshipMotor 클래스를 모방할 수 있다면, CanFire 메서드를 가로채서 원할 때마다 참 또는 거짓을 반환하도록 만들면 됩니다. 테스트 시간에 구애받지 않고 진행할 수 있습니다. 하지만 스페이스십 모터는 모노비헤이비어에서 상속받았기 때문에 모방할 수 없으므로 겸손한 오브젝트 패턴을 적용해야 합니다.
어떻게 할 수 있을까요? Unity API를 사용하지 않는 모든 로직 코드를 별도의 클래스로 추출하고 이에 대한 참조를 스페이스십모터에 도입하기만 하면 됩니다. 클래스를 다시 살펴보고 무엇을 추출해야 하는지 알아봅시다. 트랜폼포지션과 인스턴스화불릿은 Unity API를 사용하지만 그 외의 모든 것은 추출할 수 있습니다. 정적 시간 클래스도 있다는 것을 알고 있지만 나중에 다시 설명하겠습니다.
실제 추출을 수행하기 전에 마지막으로 설명해야 할 것은 추출된 로직이 Unity API에 의존하지 않고 어떻게 통신하는지에 대한 것입니다. 이곳이 바로 인터페이스가 들어오는 곳입니다. 로직이 있는 클래스에는 인터페이스에 대한 참조가 있으며, 실제 구현에는 신경 쓰지 않습니다. 작업을 단순하게 유지하기 위해 이러한 인터페이스를 모노비헤이비어 자체에서 직접 구현할 수 있습니다! 다음 두 가지 수업을 살펴보겠습니다:


스페이스십모터 클래스부터 시작하겠습니다. 이 클래스는 각각 우주선의 위치 변환과 총알 인스턴스화를 담당하는 몇 가지 인터페이스를 구현했습니다. 클래스 자체에는 이제 모든 로직을 구현하는 스페이스십 컨트롤러를 참조하는 필드가 있습니다. 스페이스십 컨트롤러 클래스는 스페이스십 모터에 대해 아무것도 알지 못하며, 스페이스십 컨트롤러가 할 수 있는 유일한 일은 그것이 참조하는 인터페이스에서 메서드를 호출하는 것뿐입니다.
유니티는 인터페이스에 대한 레퍼런스를 직렬화하지 않습니다. 직렬화에 신경 쓰지 않는다면 SpaceshipController 클래스를 생성할 때 인터페이스 참조를 전달하기만 하면 됩니다. 그렇지 않으면 직렬화가 발생한 후 매번 호출되는 OnEnable 콜백에서 참조를 설정할 수 있습니다. 참고로, 전체 SpaceshipMotor 클래스는 일반적인 방식으로 직렬화되며, 인터페이스 참조만 손실될 뿐입니다.
스페이스십모터에서 시간 클래스 레퍼런스를 보셨을 겁니다. 여기에는 Unity API 참조가 없어야 한다고 말했지만, 시간에 따라 달라지는 종속성을 처리하는 다른 접근 방식을 보여주기 위해 그대로 두었습니다. 이상적으로는 메서드에 Time.time 값을 인자로 전달하면 됩니다.
UML 팬이라면 최종 결과물을 (단순화된) UML 다이어그램으로 볼 수 있습니다:

분리된 스페이스십모터 클래스를 사용하면 유닛 테스트를 작성하는 데 아무런 지장이 없습니다. 테스트 중 하나를 살펴보세요:

이 테스트는 총알이 남아있지 않으면 발사할 수 없는지 확인합니다. 테스트 자체는 배열-행위-주장 패턴에 따라 구조화됩니다. 배열 부분에서는 GetGunMock 및 GetControllerMock 메서드를 사용하여 오브젝트 모형을 생성합니다. GetControllerMock은 모형을 생성하는 것 외에도 항상 참을 반환하도록 CanFire 메서드의 동작을 재정의합니다. 이렇게 하면 컨트롤러 객체에서 시간 종속성이 제거됩니다. 다음으로 현재 글머리 기호 번호를 0으로 설정합니다. 그 후 컨트롤러 클래스에 불을 적용하고 총 컨트롤러 인터페이스에서 불이 호출되지 않으면 어설트합니다.
프로젝트에는 몇 가지 테스트가 더 남아 있습니다. 여기에서 가져와서 조금 가지고 놀 수 있습니다. 모킹 오브젝트로 NSubstitute를 사용했습니다. 유니티 테스트 툴과 함께 제공되는 버전도 있습니다. 여기서 설명한 세 가지 버전의 컨트롤러는 모두 프로젝트에 첨부되어 있습니다.
오늘은 여기까지입니다. 재미있게 읽으셨기를 바라며 즐거운 테스트가 되시길 바랍니다!
Tomek
