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*