# Unity 프로젝트 테스트 전략 ## 📋 개요 Unity MVVM 프로젝트에서 체계적인 테스트를 통해 코드 품질과 안정성을 보장하는 완전한 테스트 전략입니다. ## 🎯 테스트 목표 - **코드 품질 보장**: 버그 조기 발견 및 예방 - **리팩토링 안전성**: 코드 변경 시 기존 기능 보호 - **개발 효율성**: 자동화된 테스트로 수동 테스트 시간 단축 - **문서화 효과**: 테스트 코드로 사용법 명세 --- ## 🏗️ 테스트 피라미드 ``` /\ / \ / E2E \ ← 소수 (느림, 비쌈, 깨지기 쉬움) /______\ / \ / Integration\ ← 적절한 수 (중간 속도, 중간 비용) /__________\ / \ / Unit Tests \ ← 대다수 (빠름, 싸고, 안정적) /______________\ ``` ### 테스트 비율 가이드라인 - **Unit Tests**: 70% - 개별 클래스/메서드 테스트 - **Integration Tests**: 25% - 컴포넌트 간 상호작용 테스트 - **E2E Tests**: 5% - 전체 사용자 시나리오 테스트 --- ## 🧪 Unity Test Framework 설정 ### 패키지 설치 ```json // Packages/manifest.json { "dependencies": { "com.unity.test-framework": "1.3.9", "com.unity.test-framework.performance": "3.0.3" } } ``` ### 테스트 폴더 구조 ``` Assets/ └── Tests/ ├── EditMode/ # Edit Mode 테스트 │ ├── Models/ # Model 단위 테스트 │ ├── ViewModels/ # ViewModel 단위 테스트 │ ├── Services/ # Service 단위 테스트 │ └── Utils/ # 유틸리티 테스트 ├── PlayMode/ # Play Mode 테스트 │ ├── Integration/ # 통합 테스트 │ ├── UI/ # UI 테스트 │ └── Performance/ # 성능 테스트 └── TestUtilities/ # 테스트 유틸리티 ├── Mocks/ # Mock 객체들 ├── Builders/ # 테스트 데이터 빌더 └── Helpers/ # 테스트 헬퍼 ``` --- ## 1️⃣ Unit Tests (Edit Mode) ### Model 테스트 예시 ```csharp // Tests/EditMode/Models/PlayerModelTests.cs using NUnit.Framework; using UnityEngine; [TestFixture] public class PlayerModelTests { private PlayerModel _playerModel; [SetUp] public void SetUp() { _playerModel = ScriptableObject.CreateInstance<PlayerModel>(); _playerModel.Initialize(); } [TearDown] public void TearDown() { if (_playerModel != null) { Object.DestroyImmediate(_playerModel); } } [Test] public void TakeDamage_정상적인_데미지_입력시_체력이_감소한다() { // Arrange float initialHealth = _playerModel.Health.Value; float damage = 30f; // Act _playerModel.TakeDamage(damage); // Assert Assert.AreEqual(initialHealth - damage, _playerModel.Health.Value); Assert.IsTrue(_playerModel.IsAlive.Value); } [Test] public void TakeDamage_음수_데미지_입력시_체력이_변하지_않는다() { // Arrange float initialHealth = _playerModel.Health.Value; // Act _playerModel.TakeDamage(-10f); // Assert Assert.AreEqual(initialHealth, _playerModel.Health.Value); } [Test] public void TakeDamage_체력보다_큰_데미지시_체력이_0이_되고_사망한다() { // Arrange float initialHealth = _playerModel.Health.Value; float massiveDamage = initialHealth + 50f; // Act _playerModel.TakeDamage(massiveDamage); // Assert Assert.AreEqual(0f, _playerModel.Health.Value); Assert.IsFalse(_playerModel.IsAlive.Value); } [Test] public void Heal_정상적인_회복량_입력시_체력이_증가한다() { // Arrange _playerModel.TakeDamage(50f); // 체력을 50 감소 float currentHealth = _playerModel.Health.Value; // Act _playerModel.Heal(25f); // Assert Assert.AreEqual(currentHealth + 25f, _playerModel.Health.Value); } [Test] public void Heal_최대체력_초과시_최대체력으로_제한된다() { // Arrange float maxHealth = _playerModel.MaxHealth.Value; // Act _playerModel.Heal(50f); // 이미 최대 체력인 상태에서 회복 // Assert Assert.AreEqual(maxHealth, _playerModel.Health.Value); } [TestCase(1, 0f, 100f)] [TestCase(5, 400f, 500f)] [TestCase(10, 950f, 1000f)] public void GainExperience_레벨업_조건_만족시_레벨이_증가한다(int targetLevel, float expToAdd, float expectedExp) { // Act _playerModel.GainExperience(expToAdd); // Assert Assert.AreEqual(targetLevel, _playerModel.Level.Value); Assert.AreEqual(expectedExp, _playerModel.Experience.Value); } } ``` ### ViewModel 테스트 (Dependency Injection Mock) ```csharp // Tests/EditMode/ViewModels/PlayerViewModelTests.cs using NUnit.Framework; using Moq; using R3; [TestFixture] public class PlayerViewModelTests { private PlayerViewModel _viewModel; private Mock<IPlayerModel> _mockPlayerModel; private Mock<IMessagePublisher<PlayerLevelUpMessage>> _mockPublisher; [SetUp] public void SetUp() { // Mock 객체 생성 _mockPlayerModel = new Mock<IPlayerModel>(); _mockPublisher = new Mock<IMessagePublisher<PlayerLevelUpMessage>>(); // Mock 설정 SetupMockPlayerModel(); // ViewModel 생성 (수동 DI) _viewModel = new PlayerViewModel(); InjectDependencies(); _viewModel.Initialize(); } [TearDown] public void TearDown() { _viewModel?.Dispose(); } private void SetupMockPlayerModel() { _mockPlayerModel.Setup(m => m.Level).Returns(new ReactiveProperty<int>(1)); _mockPlayerModel.Setup(m => m.Health).Returns(new ReactiveProperty<float>(100f)); _mockPlayerModel.Setup(m => m.MaxHealth).Returns(new ReactiveProperty<float>(100f)); _mockPlayerModel.Setup(m => m.IsAlive).Returns(new ReactiveProperty<bool>(true)); } private void InjectDependencies() { // 리플렉션을 통한 의존성 주입 (테스트용) var playerModelField = typeof(PlayerViewModel).GetField("_playerModel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); playerModelField?.SetValue(_viewModel, _mockPlayerModel.Object); var publisherField = typeof(PlayerViewModel).GetField("_levelUpPublisher", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); publisherField?.SetValue(_viewModel, _mockPublisher.Object); } [Test] public void LevelText_레벨_변경시_올바른_형식으로_반환한다() { // Arrange int newLevel = 5; // Act _mockPlayerModel.Object.Level.Value = newLevel; // Assert Assert.AreEqual(quot;Level {newLevel}", _viewModel.LevelText.Value); } [Test] public void HealthBarFill_체력_변경시_올바른_비율로_반환한다() { // Arrange _mockPlayerModel.Object.Health.Value = 75f; _mockPlayerModel.Object.MaxHealth.Value = 100f; // Act float actualFill = _viewModel.HealthBarFill.Value; // Assert Assert.AreEqual(0.75f, actualFill, 0.01f); } [Test] public void HealCommand_실행시_모델의_Heal_메서드가_호출된다() { // Act _viewModel.HealCommand.Execute(); // Assert _mockPlayerModel.Verify(m => m.Heal(It.IsAny<float>()), Times.Once); } } ``` ### Service 테스트 ```csharp // Tests/EditMode/Services/DataServiceTests.cs [TestFixture] public class DataServiceTests { private LocalDataService _dataService; private Mock<IEncryptionService> _mockEncryption; private Mock<ILoggingService> _mockLogger; private string _testDataPath; [SetUp] public void SetUp() { _mockEncryption = new Mock<IEncryptionService>(); _mockLogger = new Mock<ILoggingService>(); _testDataPath = Path.Combine(Application.temporaryCachePath, "TestData"); Directory.CreateDirectory(_testDataPath); _dataService = new LocalDataService(_mockEncryption.Object, _mockLogger.Object); } [TearDown] public void TearDown() { if (Directory.Exists(_testDataPath)) { Directory.Delete(_testDataPath, true); } } [Test] public async System.Threading.Tasks.Task SaveAsync_정상_데이터_저장시_파일이_생성된다() { // Arrange var testData = new TestPlayerData { Name = "TestPlayer", Level = 5 }; string key = "test_player"; _mockEncryption.Setup(e => e.Encrypt(It.IsAny<string>())).Returns("encrypted_data"); // Act await _dataService.SaveAsync(key, testData); // Assert string expectedPath = Path.Combine(_testDataPath, "SaveData", quot;{key}.dat"); Assert.IsTrue(File.Exists(expectedPath)); } [Test] public async System.Threading.Tasks.Task LoadAsync_존재하지_않는_파일_로드시_기본값을_반환한다() { // Arrange var defaultData = new TestPlayerData { Name = "Default", Level = 1 }; // Act var result = await _dataService.LoadAsync("nonexistent_key", defaultData); // Assert Assert.AreEqual(defaultData.Name, result.Name); Assert.AreEqual(defaultData.Level, result.Level); } } ``` --- ## 2️⃣ Integration Tests (Play Mode) ### MVVM 통합 테스트 ```csharp // Tests/PlayMode/Integration/PlayerSystemIntegrationTests.cs using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using System.Collections; public class PlayerSystemIntegrationTests { private GameObject _testGameObject; private PlayerView _playerView; private PlayerViewModel _viewModel; private PlayerModel _playerModel; [UnitySetUp] public IEnumerator SetUp() { // 테스트용 게임오브젝트 생성 _testGameObject = new GameObject("TestPlayer"); // PlayerModel 생성 _playerModel = ScriptableObject.CreateInstance<PlayerModel>(); _playerModel.Initialize(); // PlayerView 설정 _playerView = _testGameObject.AddComponent<PlayerView>(); SetupTestUI(); // ViewModel 생성 및 의존성 주입 (테스트용 간단 주입) _viewModel = new PlayerViewModel(); InjectDependencies(); _viewModel.Initialize(); yield return null; // 한 프레임 대기 } [UnityTearDown] public IEnumerator TearDown() { _viewModel?.Dispose(); if (_testGameObject != null) { Object.DestroyImmediate(_testGameObject); } if (_playerModel != null) { Object.DestroyImmediate(_playerModel); } yield return null; } private void SetupTestUI() { // UI 컴포넌트 동적 생성 (테스트용) var canvas = new GameObject("Canvas").AddComponent<Canvas>(); canvas.transform.SetParent(_testGameObject.transform); var levelText = new GameObject("LevelText").AddComponent<UnityEngine.UI.Text>(); levelText.transform.SetParent(canvas.transform); var healthSlider = new GameObject("HealthSlider").AddComponent<UnityEngine.UI.Slider>(); healthSlider.transform.SetParent(canvas.transform); // PlayerView에 UI 참조 설정 (리플렉션 사용) SetUIReference("_levelText", levelText); SetUIReference("_healthBar", healthSlider); } private void SetUIReference(string fieldName, Component component) { var field = typeof(PlayerView).GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); field?.SetValue(_playerView, component); } [UnityTest] public IEnumerator PlayerView_모델_체력_변경시_UI가_업데이트된다() { // Arrange yield return null; // 초기화 대기 // Act _playerModel.TakeDamage(30f); // 몇 프레임 대기 (Observable 업데이트 대기) yield return new WaitForEndOfFrame(); // Assert var healthSlider = _testGameObject.GetComponentInChildren<UnityEngine.UI.Slider>(); Assert.AreEqual(0.7f, healthSlider.value, 0.1f); // 70/100 = 0.7 } [UnityTest] public IEnumerator PlayerView_레벨업시_UI_텍스트가_업데이트된다() { // Arrange yield return null; // Act _playerModel.GainExperience(100f); // 레벨업 유발 yield return new WaitForEndOfFrame(); // Assert var levelText = _testGameObject.GetComponentInChildren<UnityEngine.UI.Text>(); Assert.IsTrue(levelText.text.Contains("Level 2")); } } ``` ### UI 상호작용 테스트 ```csharp // Tests/PlayMode/Integration/UIInteractionTests.cs public class UIInteractionTests { [UnityTest] public IEnumerator Button_클릭시_커맨드가_실행된다() { // Arrange var buttonGameObject = new GameObject("TestButton"); var button = buttonGameObject.AddComponent<UnityEngine.UI.Button>(); bool commandExecuted = false; button.onClick.AddListener(() => commandExecuted = true); // Act button.onClick.Invoke(); // 버튼 클릭 시뮬레이션 yield return null; // Assert Assert.IsTrue(commandExecuted); // Cleanup Object.DestroyImmediate(buttonGameObject); } } ``` --- ## 3️⃣ Performance Tests ### Unity Performance Testing Extension 사용 ```csharp // Tests/PlayMode/Performance/PlayerPerformanceTests.cs using Unity.PerformanceTesting; using NUnit.Framework; using UnityEngine; public class PlayerPerformanceTests { [Test, Performance] public void PlayerController_Update_성능_테스트() { // Arrange var playerGameObject = new GameObject("TestPlayer"); var playerController = playerGameObject.AddComponent<PlayerController>(); // Act & Assert Measure.Method(() => { playerController.Update(); // private Update를 테스트용 public으로 노출 필요 }) .WarmupCount(5) .MeasurementCount(50) .IterationsPerMeasurement(100) .Run(); // Cleanup Object.DestroyImmediate(playerGameObject); } [Test, Performance] public void ObjectPool_Get_Return_성능_테스트() { // Arrange var poolGameObject = new GameObject("TestPool"); var bulletPool = poolGameObject.AddComponent<BulletPool>(); bulletPool.InitializePool(100); // Act & Assert Measure.Method(() => { var bullet = bulletPool.GetBullet(); bulletPool.ReturnBullet(bullet); }) .WarmupCount(10) .MeasurementCount(100) .IterationsPerMeasurement(1000) .Run(); // Cleanup Object.DestroyImmediate(poolGameObject); } } ``` ### 메모리 사용량 테스트 ```csharp [Test, Performance] public void GC_할당_최소화_테스트() { // Arrange var stringBuilder = new System.Text.StringBuilder(256); // Act & Assert Measure.Method(() => { // GC를 발생시키지 않는 문자열 생성 stringBuilder.Clear(); stringBuilder.Append("Health: "); stringBuilder.Append(100); stringBuilder.Append("/"); stringBuilder.Append(100); var result = stringBuilder.ToString(); }) .GC() .WarmupCount(5) .MeasurementCount(50) .Run(); } ``` --- ## 4️⃣ 테스트 유틸리티 ### Mock 객체 팩토리 ```csharp // Tests/TestUtilities/Mocks/MockFactory.cs public static class MockFactory { public static Mock<IPlayerModel> CreatePlayerModelMock(int level = 1, float health = 100f) { var mock = new Mock<IPlayerModel>(); mock.Setup(m => m.Level).Returns(new ReactiveProperty<int>(level)); mock.Setup(m => m.Health).Returns(new ReactiveProperty<float>(health)); mock.Setup(m => m.MaxHealth).Returns(new ReactiveProperty<float>(100f)); mock.Setup(m => m.IsAlive).Returns(new ReactiveProperty<bool>(health > 0)); return mock; } public static Mock<IDataService> CreateDataServiceMock() { var mock = new Mock<IDataService>(); mock.Setup(s => s.SaveAsync(It.IsAny<string>(), It.IsAny<object>())) .Returns(UniTask.CompletedTask); return mock; } } ``` ### 테스트 데이터 빌더 ```csharp // Tests/TestUtilities/Builders/PlayerDataBuilder.cs public class PlayerDataBuilder { private PlayerData _playerData; public PlayerDataBuilder() { _playerData = new PlayerData { PlayerId = "test_player", PlayerName = "TestPlayer", Level = 1, Experience = 0f, Stats = new PlayerStats { maxHealth = 100f, currentHealth = 100f, attackPower = 10f, defense = 5f, moveSpeed = 5f } }; } public PlayerDataBuilder WithLevel(int level) { _playerData.Level = level; return this; } public PlayerDataBuilder WithHealth(float health) { _playerData.Stats.currentHealth = health; return this; } public PlayerDataBuilder WithName(string name) { _playerData.PlayerName = name; return this; } public PlayerData Build() => _playerData; } // 사용 예시 var testPlayer = new PlayerDataBuilder() .WithLevel(5) .WithHealth(75f) .WithName("TestHero") .Build(); ``` --- ## 5️⃣ CI/CD 통합 ### GitHub Actions 워크플로우 ```yaml # .github/workflows/unity-tests.yml name: Unity Tests on: [push, pull_request] jobs: test: name: Test Unity Project runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Cache Unity Library uses: actions/cache@v2 with: path: Library key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }} - name: Run Unity Tests uses: game-ci/unity-test-runner@v2 env: UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} with: projectPath: . testMode: all artifactsPath: test-results - name: Upload Test Results uses: actions/upload-artifact@v2 if: always() with: name: test-results path: test-results ``` --- ## 6️⃣ 테스트 실행 및 리포팅 ### Unity Test Runner 사용 ```csharp // 테스트 실행 스크립트 // Editor/TestRunner.cs using UnityEditor; using UnityEngine; public class TestRunner : EditorWindow { [MenuItem("Tests/Run All Tests")] public static void RunAllTests() { EditorApplication.ExecuteMenuItem("Window/General/Test Runner"); } [MenuItem("Tests/Run Unit Tests Only")] public static void RunUnitTests() { // Edit Mode 테스트만 실행 var testRunnerApi = ScriptableObject.CreateInstance<UnityEditor.TestTools.TestRunner.Api.TestRunnerApi>(); var filter = new UnityEditor.TestTools.TestRunner.Api.Filter() { testMode = UnityEditor.TestTools.TestRunner.Api.TestMode.EditMode }; testRunnerApi.Execute(new UnityEditor.TestTools.TestRunner.Api.ExecutionSettings(filter)); } } ``` ### 커맨드라인에서 테스트 실행 ```bash # 모든 테스트 실행 Unity -batchmode -quit -projectPath . -runTests -testResults results.xml # Edit Mode 테스트만 실행 Unity -batchmode -quit -projectPath . -runTests -testMode EditMode -testResults results.xml # 특정 카테고리 테스트 실행 Unity -batchmode -quit -projectPath . -runTests -testCategory "Unit" -testResults results.xml ``` --- ## 📊 테스트 커버리지 목표 ### 커버리지 기준 - **Model 클래스**: 90% 이상 - **ViewModel 클래스**: 85% 이상 - **Service 클래스**: 80% 이상 - **Utility 클래스**: 95% 이상 - **전체 프로젝트**: 80% 이상 ### 커버리지 측정 ```csharp // Unity Code Coverage 패키지 사용 // Package Manager에서 "Code Coverage" 설치 후 사용 ``` --- ## 🔧 테스트 작성 가이드라인 ### AAA 패턴 (Arrange-Act-Assert) ```csharp [Test] public void 메서드명_조건_기대결과() { // Arrange - 테스트 준비 var testObject = new TestClass(); var expectedValue = 42; // Act - 실제 동작 수행 var actualValue = testObject.DoSomething(); // Assert - 결과 검증 Assert.AreEqual(expectedValue, actualValue); } ``` ### 테스트 네이밍 규칙 - **한글 사용 허용**: 명확한 의도 전달 - **형식**: `메서드명_조건_기대결과()` - **예시**: `TakeDamage_음수값_입력시_체력이_변하지_않는다()` --- ## 📋 테스트 체크리스트 ### 새 기능 개발 시 - [ ] **Unit Test 작성**: 새로운 클래스/메서드에 대한 단위 테스트 - [ ] **Happy Path 테스트**: 정상 동작 시나리오 테스트 - [ ] **Edge Case 테스트**: 경계값, 예외 상황 테스트 - [ ] **Integration Test**: 다른 컴포넌트와의 상호작용 테스트 - [ ] **Performance Test**: 성능이 중요한 기능의 경우 추가 ### 코드 리뷰 시 - [ ] **테스트 커버리지**: 새 코드에 대한 테스트 존재 여부 - [ ] **테스트 품질**: 의미 있는 테스트인지 확인 - [ ] **테스트 독립성**: 다른 테스트에 영향받지 않는지 확인 - [ ] **테스트 실행 속도**: 단위 테스트는 빠르게 실행되는지 확인 --- *체계적인 테스트로 안정적이고 신뢰할 수 있는 Unity 게임을 만들어보세요!* --- *최종 업데이트: 2024-01-03* *작성자: Unity Testing Team*