
지난주에는 gRPC를 통해 서버들을 멋지게 분리했습니다. 하지만 시스템이 복잡해질수록, 코드 한 줄을 수정했을 때 어떤 부작용이 일어날지 예측하기가 점점 더 어려워집니다.
이번 주는 잠시 숨을 고르며, 우리가 지금까지 만든 코드가 '정말 제대로 동작하는지' 보장해 줄 자동화된 테스트라는 강력한 무기를 장착하는 시간입니다. 🛡️
안녕하세요! 스튜디오 비를긋다입니다. 이번 챌린지의 주제는 '견고한 서버를 위한 유닛 테스트(Unit Test) 및 통합 테스트(Integration Test) 작성' 입니다. 버그가 없는 코드는 없습니다. 하지만 버그를 출시 전에, 그리고 자동으로 찾아내는 시스템은 만들 수 있습니다. xUnit, Moq, 그리고 인메모리(In-Memory) DB를 사용해 서버의 안정성을 극대화해 보겠습니다.
C# 서버의 버그, 아직도 수동으로 잡으시나요? xUnit, Moq, 인메모리 DB를 활용한 유닛 테스트 및 통합 테스트 자동화 방법을 실전 코드와 함께 자세히 다룹니다. 지금 바로 견고한 .NET 서버를 구축해 보세요.
'아르카디아의 그림자'의 기능 개발은 순조롭게 진행 중이지만, 최근 CI/CD 파이프라인에서 원인을 알 수 없는 버그들이 간헐적으로 발생하고 있습니다. "물약을 사용했는데 체력이 안 차는 버그", "로그아웃 후 재접속하니 인벤토리가 롤백되는 버그" 등 치명적인 오류가 리포트되기 시작했습니다. 원인 분석 결과, 새로운 기능 추가 시 기존 로직을 의도치 않게 변경했기 때문으로 밝혀졌습니다.
당신의 임무는 이런 문제를 사전에 방지하기 위해, 이전 주차에 만들었던 PlayerAgent와 SqlitePlayerRepository에 대한 자동화된 테스트 스위트(Test Suite)를 구축하는 것입니다.
PlayerAgent의 로직을 테스트합니다.PlayerAgent가 의존하는 IPlayerRepository는 Moq 라이브러리를 사용해 '가짜(Mock)' 객체로 대체해야 합니다. (DB와 독립적으로 로직만 테스트)UseItemAction을 처리했을 때, Health 상태가 올바르게 변경되고 Inventory에서 아이템이 제거되는지 검증합니다.SqlitePlayerRepository의 기능을 테스트합니다.SavePlayerDataAsync로 데이터를 저장한 후, GetPlayerDataAsync로 불러왔을 때 데이터가 일치하는지 검증합니다.사전 준비:
테스트 프로젝트에 아래 NuGet 패키지들을 추가해야 합니다.dotnet add package xunitdotnet add package Moqdotnet add package Microsoft.EntityFrameworkCore.Sqlite.Core (인메모리 DB 사용을 위해)dotnet add package Dapper (테스트 대상이므로)dotnet add package Microsoft.Data.Sqlite (테스트 대상이므로)
using Moq;
using Xunit;
public class PlayerAgentTests
{
private readonly Mock<IPlayerRepository> _mockRepo;
private readonly PlayerAgent _agent;
public PlayerAgentTests()
{
// 1. 가짜(Mock) 리포지토리 생성
_mockRepo = new Mock<IPlayerRepository>();
// 2. 테스트 대상인 PlayerAgent 생성 시, 실제 DB가 아닌 가짜 객체 주입
// (PlayerAgent 생성자가 LoadStateAction을 호출하므로, 이에 대한 Mock 설정이 필요할 수 있음)
_agent = new PlayerAgent(123, _mockRepo.Object);
}
[Fact]
public async Task ProcessAction_UseItemAction_ShouldHealAndRemoveItem()
{
// Arrange (준비)
// PlayerAgent의 초기 상태 설정 (로직을 통해)
_agent.Health = 50;
_agent.Inventory.Add(1); // 1번 아이템 (체력 물약)
var useItemAction = new UseItemAction(1);
// Act (실행)
// PlayerAgent의 내부 private 메서드를 테스트하기 위해, public 메서드(PostActionAsync)를 사용
await _agent.PostActionAsync(useItemAction);
// 메일박스 처리를 위해 잠시 대기 (실제 환경에서는 TestScheduler 등을 사용할 수 있음)
await Task.Delay(100);
// Assert (검증)
Assert.Equal(70, _agent.Health); // 체력이 20 올랐는지 확인 (50 + 20)
Assert.DoesNotContain(1, _agent.Inventory); // 인벤토리에서 아이템이 사라졌는지 확인
}
[Fact]
public async Task DisposeAsync_ShouldCall_SaveStateAsync()
{
// Arrange
// SavePlayerDataAsync가 한 번 호출될 것임을 '설정' (검증을 위함)
_mockRepo.Setup(r => r.SavePlayerDataAsync(It.IsAny<PlayerData>()))
.Returns(Task.CompletedTask);
// Act
// 에이전트를 종료시킵니다. (종료 시 SaveStateAction이 메일박스에 들어감)
await _agent.DisposeAsync();
// Assert
// SavePlayerDataAsync가 '정확히 한 번' 호출되었는지 검증합니다.
_mockRepo.Verify(r => r.SavePlayerDataAsync(It.IsAny<PlayerData>()), Times.Once);
}
}
using Xunit;
using Microsoft.Data.Sqlite;
using System.Text.Json;
public class SqlitePlayerRepositoryTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly SqlitePlayerRepository _repository;
public SqlitePlayerRepositoryTests()
{
// 1. 인메모리 SQLite 연결 생성 (Mode=Memory)
// "DataSource=:memory:"는 단일 연결에서만 유효하지만,
// "DataSource=SharedMemory;Mode=Memory;Cache=Shared"는 여러 연결 간 공유 가능
_connection = new SqliteConnection("DataSource=SharedMemory;Mode=Memory;Cache=Shared");
_connection.Open(); // 공유 인메모리 DB를 활성화하기 위해 연결을 열어둡니다.
// 2. SqlitePlayerRepository는 DB 파일 경로 대신 '연결 문자열'을 받도록 수정하는 것이 좋음
// (또는 테스트용으로 InitializeDatabase를 수동 호출)
_repository = new SqlitePlayerRepository("DataSource=SharedMemory;Mode=Memory;Cache=Shared");
// 3. 테스트마다 스키마를 새로 생성 (테스트 격리)
// (실제로는 Repository 생성자에서 InitializeDatabase를 호출)
}
public void Dispose()
{
// 테스트가 끝나면 연결을 닫아 인메모리 DB를 파괴합니다.
_connection.Close();
}
[Fact]
public async Task SavePlayerDataAsync_And_GetPlayerDataAsync_ShouldWork()
{
// Arrange (준비)
var testInventory = new List<int> { 101, 205 };
var testData = new PlayerData
{
PlayerId = 1,
PositionX = 10.5f,
PositionY = -5.0f,
Health = 80,
InventoryJson = JsonSerializer.Serialize(testInventory)
};
// Act (실행)
// 1. 데이터를 저장합니다.
await _repository.SavePlayerDataAsync(testData);
// 2. 저장한 데이터를 다시 불러옵니다.
var loadedData = await _repository.GetPlayerDataAsync(1);
// Assert (검증)
Assert.NotNull(loadedData);
Assert.Equal(1, loadedData.PlayerId);
Assert.Equal(10.5f, loadedData.PositionX);
Assert.Equal(80, loadedData.Health);
var loadedInventory = JsonSerializer.Deserialize<List<int>>(loadedData.InventoryJson);
Assert.Equal(testInventory, loadedInventory);
}
[Fact]
public async Task SavePlayerDataAsync_ShouldUpsert_ExistingData()
{
// Arrange (준비) - 초기 데이터 삽입
var initialData = new PlayerData { PlayerId = 2, Health = 100 };
await _repository.SavePlayerDataAsync(initialData);
// Act (실행) - 동일한 PlayerId로 데이터를 덮어씁니다.
var updatedData = new PlayerData { PlayerId = 2, Health = 50 };
await _repository.SavePlayerDataAsync(updatedData);
var loadedData = await _repository.GetPlayerDataAsync(2);
// Assert (검증)
Assert.NotNull(loadedData);
Assert.Equal(50, loadedData.Health); // 체력이 100이 아닌 50으로 업데이트되었는지 확인
}
}
이 테스트 스위트가 구축되면, 개발자는 새로운 기능을 추가하거나 코드를 리팩토링할 때마다 모든 테스트를 실행합니다.
PlayerAgent의 내부 로직을 더 효율적으로 변경한 후, 유닛 테스트를 실행해봅니다. 테스트가 모두 통과하면, 최소한 기존 기능은 망가뜨리지 않았다는 확신을 가질 수 있습니다.Players 테이블에 'Gold'라는 새 컬럼을 추가해야 합니다. 통합 테스트에 'Gold' 저장/조회 테스트 케이스를 추가하고, SqlitePlayerRepository의 쿼리를 수정하여 테스트를 통과시킵니다.Moq 라이브러리를 사용하여 의존성을 격리하고 오직 '테스트 대상'의 로직에만 집중하는 방법을 학습합니다.TDD(Test-Driven Development, 테스트 주도 개발) 방식으로 새로운 기능을 추가해 보세요.
과제: PlayerAgent에 '골드(Gold)' 추가하기
요구사항:
PlayerAgent가 AddGoldAction을 받았을 때 Gold 속성값이 증가해야 한다는 실패하는 유닛 테스트를 먼저 작성합니다. (컴파일조차 되지 않을 것입니다.)PlayerAgent에 public long Gold { get; private set; } 속성과, AddGoldAction을 처리하는 로직을 추가하여 1번의 테스트를 통과시킵니다.PlayerAgent가 종료될 때(Dispose), PlayerData에 Gold 값이 포함되어 SavePlayerDataAsync가 호출되어야 한다는 실패하는 유닛 테스트를 Moq을 이용해 작성합니다.PlayerAgent의 SaveStateAsync 로직과 PlayerData 클래스에 Gold 속성을 추가하여 3번의 테스트를 통과시킵니다.SqlitePlayerRepository가 Gold 값을 DB에 저장하고 불러올 수 있어야 한다는 실패하는 통합 테스트를 작성합니다.SqlitePlayerRepository의 InitializeDatabase, SavePlayerDataAsync 쿼리를 수정하여 5번의 테스트를 통과시킵니다.이 과정을 통해, 테스트가 어떻게 개발을 '이끌어 가는지' 직접 체험해 보세요.
이번 주는 서버의 '안정성'이라는 내공을 쌓는 중요한 시간입니다. 튼튼한 테스트 코드는 미래의 여러분을 수많은 버그의 고통에서 구원해 줄 것입니다!
| 7.주간 C# 서버 챌린지: 분산 마이크로서비스 (0) | 2025.10.22 |
|---|---|
| 6.주간 C# 서버 챌린지: MemoryPack으로 패킷 처리 시스템 구축하기 (0) | 2025.10.11 |
| 5.주간 C# 서버 챌린지: MMORPG 서버 개발의 핵심, C# 비동기 소켓 프로그래밍 (0) | 2025.10.02 |
| 4.주간 C# 서버 챌린지: 비동기 I/O와 리포지토리 패턴으로 플레이어 데이터 저장하기 (0) | 2025.09.24 |
| 3.주간 C# 서버 챌린지: '락(Lock)' 없는 플레이어 상태 관리 (0) | 2025.09.17 |