상세 컨텐츠

본문 제목

8.주간 C# 서버 챌린지: 유닛 테스트 및 통합 테스트

개발 & 프로그래밍/C# 서버 챌린지

by Jiung. 2025. 11. 1. 17:55

본문

반응형

 

지난주에는 gRPC를 통해 서버들을 멋지게 분리했습니다. 하지만 시스템이 복잡해질수록, 코드 한 줄을 수정했을 때 어떤 부작용이 일어날지 예측하기가 점점 더 어려워집니다.

 

이번 주는 잠시 숨을 고르며, 우리가 지금까지 만든 코드가 '정말 제대로 동작하는지' 보장해 줄 자동화된 테스트라는 강력한 무기를 장착하는 시간입니다. 🛡️


주간 C# 서버 챌린지: xUnit과 Moq을 활용한 실용적인 테스트 코드 작성법

안녕하세요! 스튜디오 비를긋다입니다. 이번 챌린지의 주제는 '견고한 서버를 위한 유닛 테스트(Unit Test) 및 통합 테스트(Integration Test) 작성' 입니다. 버그가 없는 코드는 없습니다. 하지만 버그를 출시 전에, 그리고 자동으로 찾아내는 시스템은 만들 수 있습니다. xUnit, Moq, 그리고 인메모리(In-Memory) DB를 사용해 서버의 안정성을 극대화해 보겠습니다.

C# 서버의 버그, 아직도 수동으로 잡으시나요? xUnit, Moq, 인메모리 DB를 활용한 유닛 테스트 및 통합 테스트 자동화 방법을 실전 코드와 함께 자세히 다룹니다. 지금 바로 견고한 .NET 서버를 구축해 보세요.

문제: PlayerAgent 및 Repository 테스트 코드 작성

상황 시나리오

'아르카디아의 그림자'의 기능 개발은 순조롭게 진행 중이지만, 최근 CI/CD 파이프라인에서 원인을 알 수 없는 버그들이 간헐적으로 발생하고 있습니다. "물약을 사용했는데 체력이 안 차는 버그", "로그아웃 후 재접속하니 인벤토리가 롤백되는 버그" 등 치명적인 오류가 리포트되기 시작했습니다. 원인 분석 결과, 새로운 기능 추가 시 기존 로직을 의도치 않게 변경했기 때문으로 밝혀졌습니다.

 

당신의 임무는 이런 문제를 사전에 방지하기 위해, 이전 주차에 만들었던 PlayerAgentSqlitePlayerRepository에 대한 자동화된 테스트 스위트(Test Suite)를 구축하는 것입니다.

핵심 요구사항

  1. 테스트 프로젝트 생성: 솔루션에 xUnit 테스트 프로젝트를 새로 추가합니다.
  2. 유닛 테스트 (Unit Test):
    • PlayerAgent의 로직을 테스트합니다.
    • PlayerAgent가 의존하는 IPlayerRepositoryMoq 라이브러리를 사용해 '가짜(Mock)' 객체로 대체해야 합니다. (DB와 독립적으로 로직만 테스트)
    • 테스트 케이스 예: UseItemAction을 처리했을 때, Health 상태가 올바르게 변경되고 Inventory에서 아이템이 제거되는지 검증합니다.
  3. 통합 테스트 (Integration Test):
    • SqlitePlayerRepository의 기능을 테스트합니다.
    • 실제 파일이 아닌, 인메모리(In-memory) SQLite 데이터베이스를 사용하여 테스트가 독립적으로, 그리고 빠르게 실행되도록 구성해야 합니다.
    • 테스트 케이스 예: SavePlayerDataAsync로 데이터를 저장한 후, GetPlayerDataAsync로 불러왔을 때 데이터가 일치하는지 검증합니다.

샘플 솔루션 (C# 12 / .NET 8)

사전 준비:
테스트 프로젝트에 아래 NuGet 패키지들을 추가해야 합니다.
dotnet add package xunit
dotnet add package Moq
dotnet add package Microsoft.EntityFrameworkCore.Sqlite.Core (인메모리 DB 사용을 위해)
dotnet add package Dapper (테스트 대상이므로)
dotnet add package Microsoft.Data.Sqlite (테스트 대상이므로)


1. 유닛 테스트 (PlayerAgent) - Moq 사용

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);
    }
}

2. 통합 테스트 (SqlitePlayerRepository) - In-Memory DB 사용

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의 내부 로직을 더 효율적으로 변경한 후, 유닛 테스트를 실행해봅니다. 테스트가 모두 통과하면, 최소한 기존 기능은 망가뜨리지 않았다는 확신을 가질 수 있습니다.
  • DB 마이그레이션: Players 테이블에 'Gold'라는 새 컬럼을 추가해야 합니다. 통합 테스트에 'Gold' 저장/조회 테스트 케이스를 추가하고, SqlitePlayerRepository의 쿼리를 수정하여 테스트를 통과시킵니다.
  • CI/CD 연동: 이 테스트들을 GitHub Actions나 Jenkins 같은 CI/CD 파이프라인에 통합합니다. 이제 누군가 버그가 있는 코드를 커밋(Commit)하면, 테스트가 실패하며 빌드가 중단되고 즉시 알림을 받게 됩니다.

학습 목표

  • 테스트의 두 종류: 유닛 테스트(격리된 로직 검증)와 통합 테스트(여러 컴포넌트 간의 상호작용 검증)의 차이와 목적을 명확히 이해합니다.
  • 모의 객체(Mocking): Moq 라이브러리를 사용하여 의존성을 격리하고 오직 '테스트 대상'의 로직에만 집중하는 방법을 학습합니다.
  • 인메모리 DB 테스트: 실제 DB를 사용하지만, 디스크 I/O 없이 빠르고 격리된 테스트 환경을 구축하기 위해 인메모리 SQLite를 활용하는 방법을 익힙니다.
  • 테스트 주도 개발(TDD)의 기초: 테스트 케이스를 먼저 작성하고 이를 통과하는 코드를 작성하는 개발 방식의 기본 개념을 이해합니다.
  • Arrange-Act-Assert (AAA) 패턴: '준비-실행-검증'으로 이어지는 테스트 코드의 표준적인 구조를 체득합니다.

솔루션 품질 평가 기준

  • 격리성: 유닛 테스트가 외부 요인(DB, 네트워크 등)에 전혀 의존하지 않고 독립적으로 실행되는가?
  • 신뢰성: 테스트가 가끔 성공하고 가끔 실패하는(Flaky) 불안정한 테스트가 아니라, 항상 동일한 결과를 반환하는가?
  • 가독성: 테스트 코드 자체가 하나의 '문서'처럼, 해당 기능이 '어떻게 동작해야 하는지' 명확하게 설명해 주는가?
  • 커버리지: 테스트 케이스가 핵심 로직(정상 케이스)뿐만 아니라, 예외 상황이나 경계값(Edge Case)까지 충분히 검증하고 있는가?

보너스 목표

TDD(Test-Driven Development, 테스트 주도 개발) 방식으로 새로운 기능을 추가해 보세요.

과제: PlayerAgent에 '골드(Gold)' 추가하기

요구사항:

  1. [TEST] 실패하는 테스트 작성: PlayerAgentAddGoldAction을 받았을 때 Gold 속성값이 증가해야 한다는 실패하는 유닛 테스트를 먼저 작성합니다. (컴파일조차 되지 않을 것입니다.)
  2. [CODE] 최소한의 코드 작성: PlayerAgentpublic long Gold { get; private set; } 속성과, AddGoldAction을 처리하는 로직을 추가하여 1번의 테스트를 통과시킵니다.
  3. [TEST] 실패하는 테스트 작성: PlayerAgent가 종료될 때(Dispose), PlayerDataGold 값이 포함되어 SavePlayerDataAsync가 호출되어야 한다는 실패하는 유닛 테스트Moq을 이용해 작성합니다.
  4. [CODE] 코드 수정: PlayerAgentSaveStateAsync 로직과 PlayerData 클래스에 Gold 속성을 추가하여 3번의 테스트를 통과시킵니다.
  5. [TEST] 실패하는 테스트 작성: SqlitePlayerRepositoryGold 값을 DB에 저장하고 불러올 수 있어야 한다는 실패하는 통합 테스트를 작성합니다.
  6. [CODE] 코드 수정: SqlitePlayerRepositoryInitializeDatabase, SavePlayerDataAsync 쿼리를 수정하여 5번의 테스트를 통과시킵니다.

이 과정을 통해, 테스트가 어떻게 개발을 '이끌어 가는지' 직접 체험해 보세요.


이번 주는 서버의 '안정성'이라는 내공을 쌓는 중요한 시간입니다. 튼튼한 테스트 코드는 미래의 여러분을 수많은 버그의 고통에서 구원해 줄 것입니다!

반응형

관련글 더보기