IL2CPP 내부: 테스트 프레임워크

IL2CPP 팀은 테스트 우선 개발 정신이 강합니다. IL2CPP 코드의 대부분은 테스트 중심 개발 (TDD) 방식을 사용하여 작성되며, 상당한 테스트 범위 없이 IL2CPP 코드에 병합되는 풀 리퀘스트는 거의 없습니다.
IL2CPP는 ECMA 335 사양이라는 한정된(다소 많지만) 입력 세트를 가지고 있기 때문에 개발 프로세스가 TDD 개념과 잘 맞습니다. 대부분의 테스트는 프로덕션 코드가 작성되기 전에 작성되며, 이러한 테스트는 항상 예상되는 방식으로 실패해야 통과할 수 있는 코드가 작성됩니다.
이 프로세스는 IL2CPP의 설계를 추진하는 데 도움이 될 뿐만 아니라 개발 팀에게 IL2CPP의 기존 동작을 거의 모두 실행하고 빠르게 실행되는 대규모 테스트 뱅크를 제공합니다. 개발 팀으로서 이 테스트 스위트는 두 가지 중요한 이점을 제공합니다.
1) 자신감: IL2CPP에서 리팩터링 코드를 변경하는 대부분의 작업은 매우 자신 있게 수행할 수 있습니다. 테스트가 통과되면 회귀가 도입되었을 가능성이 매우 낮습니다.
2) 문제 해결: IL2CPP의 코드는 우리가 예상한 대로 작동하기 때문에 버그는 거의 항상 코드의 구현되지 않은 부분이나 아직 고려하지 않은 경우에 발생합니다. 이러한 방식으로 특정 버그의 가능한 원인 범위를 좁히면 훨씬 더 빠르게 버그를 수정할 수 있습니다.
IL2CPP 코드 기반에 대해 실행하는 다양한 유형의 테스트는 몇 가지 수준으로 나뉩니다. 현재 각 레벨별 테스트 수는 다음과 같습니다(각 테스트 유형이 실제로 어떤 것인지 아래에서 설명하겠습니다).
- 단위 테스트
- C#: 472
- C++: 44
- 통합 테스트
- C#: 1735
- IL: 173
이 모든 테스트가 녹색으로 표시되면 그 순간 IL2CPP를 출시할 수 있다고 확신합니다. 유니티는 IL2CPP에 대해 하나의 메인 개발 브랜치를 유지하며, 이 브랜치는 항상 유니티 전체의 개발을 위한 최신 브랜치를 추적합니다. 이 메인 개발 브랜치에서는 테스트가 항상 녹색으로 표시됩니다. 고장이 나면(가끔씩 발생하긴 하지만) 보통 몇 분 안에 누군가가 고쳐줍니다.
우리 팀의 개발자들은 개인 개발을 위해 이 메인 브랜치를 자주 포크하기 때문에 항상 녹색 상태여야 합니다. 메인 개발 브랜치와 개인 브랜치 모두의 빌드 및 테스트 상태는 유니티의 내부 빌드 관리 시스템인 카타나에서 유지 관리됩니다.
이러한 모든 테스트를 실행하는 데 NUnit을 사용하며 다음 세 가지 방법 중 하나로 NUnit을 구동합니다.
테스트 유형
위에서 별다른 설명 없이 네 가지 유형의 테스트를 언급했습니다. 이러한 각 유형의 테스트는 서로 다른 목적을 가지고 있으며, 모두 함께 작동하여 IL2CPP 개발을 계속 진행하는 데 도움이 됩니다.
단위 테스트는 일반적으로 메서드와 같은 작은 코드의 동작을 검증합니다. 상황을 설정하고, 테스트 중인 코드를 실행한 다음, 마지막으로 예상되는 동작을 확인합니다.
IL2CPP에 대한 통합 테스트는 실제로 어셈블리에서 il2cpp.exe 유틸리티를 실행하고 생성된 C++ 코드를 실행 파일로 컴파일한 다음 실행 파일을 실행합니다. IL2CPP 동작(Unity에서 사용되는 기존 버전의 Mono)에 대한 좋은 레퍼런스가 있으므로, 이러한 통합 테스트는 Mono(및 Windows의 경우 .Net)에서도 동일한 어셈블리를 실행합니다. 그런 다음 테스트 러너가 덤프된 두 번(또는 세 번)의 실행 결과를 표준 출력과 비교하고 차이점을 보고합니다. 따라서 IL2CPP 통합 테스트에는 단위 테스트처럼 테스트 코드에 명시적인 예상 값이나 어설션이 나열되어 있지 않습니다.
C# 단위 테스트
이 테스트는 우리가 작성하는 가장 빠르고 가장 낮은 수준의 테스트입니다. IL2CPP용 AOT 컴파일러 유틸리티인 il2cpp.exe의 여러 부분의 동작을 검증하는 데 사용됩니다. il2cpp.exe는 전적으로 C#으로 작성되었기 때문에 빠른 C# 단위 테스트를 사용하여 변경 사항을 신속하게 처리할 수 있습니다. 모든 C# 단위 테스트는 멋진 개발 머신에서 몇 초 만에 완료됩니다.
C++ 단위 테스트
IL2CPP의 런타임 코드 대부분(libil2cpp라고 함)은 C++로 작성되었습니다. 공개 API에서 쉽게 액세스할 수 없는 코드의 일부에 대해서는 C++ 단위 테스트를 사용합니다. libil2cpp의 코드 동작은 대부분 대규모 통합 테스트 스위트를 통해 실행할 수 있기 때문에 이러한 테스트는 상대적으로 적습니다. 이러한 테스트는 단위 테스트가 실행될 때 예상보다 많은 시간이 소요되는데, 이는 il2cpp.exe를 직접 실행하여 픽스처 데이터를 설정해야 하기 때문입니다.
C# 통합 테스트
IL2CPP를 위한 가장 크고 포괄적인 테스트 스위트는 C# 통합 테스트 스위트입니다. 이러한 테스트는 아이콜, 코드 생성, p/인보크 및 일반 동작을 검증하는 테스트에 중점을 두고 더 작은 세그먼트로 나뉩니다. 이 테스트 모음에 포함된 대부분의 테스트는 5~10줄 정도로 다소 짧습니다. 전체 제품군은 대부분의 시스템에서 1분 이내에 실행되지만, 스트리핑 및 코드 생성과 관련된 다양한 IL2CPP 옵션을 사용하여 실행할 수 있습니다.
IL 통합 테스트
이러한 테스트는 툴체인에서 C# 통합 테스트와 유사합니다. 하지만 C#으로 테스트 코드를 작성하는 대신 ILGenerator 클래스를 사용하여 어셈블리를 직접 생성합니다. 이러한 테스트는 C# 테스트보다 작성하는 데 시간이 조금 더 걸릴 수 있지만 유연성이 향상됩니다. 유효하지 않거나 현재 Mono C# 컴파일러에서 생성되지 않는 IL 코드에 문제가 발생하는 경우가 종종 있습니다. 이러한 경우 IL 코드로 좋은 테스트 케이스를 작성할 수 있는 경우가 많습니다. 이 테스트는 약간의 차이가 있지만 동작이 명확한 conv.i (및 그 계열의 유사한 옵코드)와 같은 옵코드를 포괄적으로 테스트하는 데도 유용합니다. 모든 IL 테스트는 1분 이내에 처음부터 끝까지 완료됩니다.
저희는 이 모든 테스트를 카타나에서 다양한 변형과 옵션을 통해 실행합니다. 소스 코드를 새로 가져와서 테스트 실행을 완료하기까지 빌드 팜의 부하에 따라 약 20~30분의 런타임이 소요됩니다.
이러한 설명에 따르면 IL2CPP에 대한 테스트 피라미드가 거꾸로 된 것처럼 보일 수 있습니다. 실제로 피라미드의 맨 위에 있는 엔드투엔드 통합 테스트가 테스트 범위의 대부분을 차지합니다.
테스트 시간이 몇 초 이상 걸리는 TDD 연습을 따라하는 것도 어려울 수 있습니다. 유니티는 통합 테스트 스위트의 개별 세그먼트를 실행하고 테스트 스위트에서 생성된 C++ 코드를 점진적으로 빌드하는 방식으로 이러한 문제를 완화하기 위해 노력하고 있습니다(IL2CPP를 통해 Unity 프로젝트의 점진적 빌드 가능성을 입증하고 있으니 계속 지켜봐 주시기 바랍니다). 그러면 개별 테스트의 처리 시간은 합리적입니다(여전히 우리가 원하는 만큼 빠르지는 않지만).
하지만 이렇게 통합 테스트를 많이 사용하는 것은 의식적인 결정이었습니다. 2015년 1월에 처음 공개되었을 때만 해도 IL2CPP의 많은 코드가 이전과 달라져 보였습니다. IL2CPP 코드 기반이 처음 만들어진 이래로 많은 것을 배우고 많은 구현 세부 사항을 변경했지만, 수년 전에 작성된 원본 테스트가 여전히 많이 남아 있습니다. 다양한 수준에서 테스트를 시도한 결과(생성된 C++ 소스 코드의 내용 검증까지 포함), 이러한 통합 테스트가 런타임 대비 안정성 테스트 비율이 가장 좋다고 판단했습니다. IL2CPP 코드에 변경 사항이 있을 때 기존 통합 테스트 중 하나를 수정해야 하는 경우는 거의 없습니다. 이 사실은 테스트 실패를 유발하는 코드 변경이 정말 문제라는 엄청난 확신을 줍니다. 또한 IL2CPP 코드를 두려움 없이 얼마든지 리팩터링하고 개선할 수 있습니다.
IL2CPP 자체 외에도 IL2CPP 코드는 훨씬 더 큰 Unity 테스트 에코시스템에 적합합니다. IL2CPP를 지원하는 각 플랫폼에 대해 유니티는 Unity 플레이어 런타임 테스트를 실행합니다. 이 테스트는 1000개 이상의 씬이 포함된 단일 Unity 프로젝트를 빌드한 다음 각 씬을 실행하고 어설션을 통해 예상 동작을 검증합니다. 일반적으로 IL2CPP 변경에 대해 이 제품군에 새로운 테스트를 추가하지 않습니다(이러한 테스트는 일반적으로 더 낮은 수준에서 이루어집니다). 이 제품군은 특정 플랫폼에서 IL2CPP를 사용할 때 발생할 수 있는 회귀에 대한 점검 역할을 합니다. 또한 이 제품군을 사용하면 IL2CPP를 Unity 빌드 툴체인에 통합하는 데 사용되는 코드를 테스트할 수 있으며, 이 역시 플랫폼마다 다릅니다. 일반적인 런타임 테스트 스위트는 약 60~90분 정도면 완료되지만, 로컬에서 개별 테스트를 훨씬 빠르게 실행하는 경우가 많습니다.
IL2CPP에 사용하는 가장 크고 느린 테스트는 Unity 에디터 통합 테스트입니다. 이러한 각 테스트는 실제로 서로 다른 Unity 에디터 인스턴스를 실행합니다. 대부분의 IL2CPP 에디터 통합 테스트는 보통 다양한 에디터 빌드 설정으로 프로젝트를 실행하는 데 중점을 둡니다. 이러한 테스트를 통해 복잡한 에디터 통합, 오류 메시지 보고, 프로젝트 빌드 크기 등을 검증합니다(그 외에도 여러 가지가 있습니다). 플랫폼에 따라 다르지만 통합 테스트 스위트는 몇 시간 내에 실행되며, 보통 더 자주 실행되지는 않더라도 적어도 매일 밤 실행됩니다.
유니티의 기본 원칙 중 하나는 "어려운 문제를 해결한다"입니다. 저는 문제의 난이도를 실패의 관점에서 생각하는 것을 좋아합니다. 해결하기 어려운 문제일수록 더 많은 실패를 겪어야 해결책을 찾을 수 있습니다.
Unity에서 스크립팅 백엔드로 사용할 고성능의 이식성이 뛰어난 새로운 AOT 컴파일러와 가상 머신을 만드는 것은 어려운 문제입니다. 말할 필요도 없이, 우리는 그 과정에서 수천 번의 실패를 경험했습니다. 해결해야 할 문제가 더 많아지고 실패도 더 많아집니다. 하지만 이러한 거의 모든 실패에서 유용한 정보를 포괄적이고 빠른 테스트 스위트로 캡처하면 매우 빠르게 반복할 수 있습니다.
IL2CPP 개발자에게 테스트 스위트는 버그가 없는 코드를 검증하거나(버그가 발견되긴 하지만) IL2CPP를 여러 플랫폼에 포팅하는 데 도움이 되는 수단(물론 그것도 가능하지만)이 아니라 사용자가 멋진 것을 만드는 데 집중할 수 있도록 빠르게 실패하고 어려운 문제를 해결하는 데 사용할 수 있는 도구입니다.
IL2CPP 내부 시리즈 포스팅을 재미있게 읽어주셨기를 바랍니다. 가능한 경우 구현 세부 정보를 공유하고 디버깅 및 성능에 대한 힌트를 제공해드리겠습니다. IL2CPP의 설계 및 구현과 관련된 다른 주제에 대해 더 자세히 알고 싶으시면 알려주세요.
