콘텐츠 대표 이미지 - xUnit으로 날개 달기: C# 개발자, 테스트는 이제 자신감이다!

xUnit으로 날개 달기: C# 개발자, 테스트는 이제 자신감이다!

코드를 짜고 나서 '이거... 잘 돌아가겠지?' 하며 불안에 떨던 날들은 이제 안녕.
C# 프로젝트에 xUnit으로 탄탄한 안전망을 치는 법, 지금부터 내가 전부 알려줄게!

들어가며: 야, 너두 테스트 할 수 있어!

안녕! 코딩하는 친구들. 우리 솔직해져 보자. 새로 기능 하나 만들고, 버그 하나 잡고 나서 소스 커밋할 때, 마음 한구석이 서늘해지는 경험, 다들 있지 않아? 😅
"내가 수정한 것 때문에 다른 기능이 죽으면 어떡하지?", "이 로직, 모든 케이스에 다 대응될까?" 하는 불안감 말이야.

이런 개발자의 숙명과도 같은 불안감을 한 방에 날려버릴 수 있는 비장의 무기가 바로 **단위 테스트(Unit Test)**야. 그리고 C# 생태계에서 가장 힙하고 강력한 테스트 프레임워크 중 하나가 바로 xUnit이지.

"아, 테스트... 그거 맨날 시간 없어서 못하는 거 아니야?" 라고 생각했다면, 오늘 이 글을 끝까지 읽어봐. 생각이 180도 바뀔 테니까.
단위 테스트는 귀찮은 추가 업무가 아니라, 너의 코드를 지켜주는 든든한 갑옷이자, 리팩토링을 할 수 있는 용기를 주는 마법의 물약이야. 특히 xUnit은 다른 프레임워크보다 훨씬 직관적이고 깔끔해서, 처음 시작하는 친구들도 금방 익숙해질 수 있어.

이 글에서는 xUnit이 대체 뭔지, 왜 써야 하는지부터 시작해서, 실제 프로젝트에 어떻게 적용하고, 테스트 코드를 '잘' 짜는 꿀팁까지 전부 다 알려줄 거야. 마치 옆자리 사수가 하나하나 짚어주듯, 친절하고 재미있게 말이지. 준비됐어? 그럼, 지금부터 C# 코드에 자신감이라는 날개를 달아보자고!

1. xUnit, 넌 대체 누구냐?

좋아, 본격적으로 시작하기 전에 xUnit의 정체부터 파헤쳐 보자. xUnit은 .NET 플랫폼을 위한 **무료, 오픈 소스, 커뮤니티 중심의 단위 테스트 프레임워크**야. NUnit과 MSTest의 원조 개발자들이 "아, 우리가 예전에 만들었던 거, 좀 더 모던하게 개선해볼까?" 하는 생각으로 만들었지. 그래서 기존 프레임워크들의 장점은 흡수하고, 단점은 개선한, 아주 세련된 녀석이라고 할 수 있어.

xUnit의 핵심 철학: 격리(Isolation)

xUnit을 다른 테스트 프레임워크와 구분 짓는 가장 큰 특징은 바로 **'테스트 격리'**에 대한 강력한 철학이야. 이게 무슨 말이냐면, xUnit은 각각의 테스트 메소드를 실행할 때마다 **테스트 클래스의 새로운 인스턴스(객체)를 생성**한다는 거야.

이게 왜 중요할까? 한번 상상해봐. 여러 테스트가 하나의 객체를 공유해서 쓴다고 생각해봐. 테스트 A가 객체의 상태를 '1'로 바꿨어. 그 다음에 실행되는 테스트 B는 객체의 상태가 '0'일 거라고 예상하고 코드를 짰는데, 이미 '1'로 바뀌어 있으니 테스트가 실패하겠지? 이런 식으로 테스트끼리 서로 영향을 주면, 테스트 결과의 신뢰도가 떨어지고 원인 모를 에러 때문에 머리를 쥐어뜯게 될 거야.

xUnit은 이런 문제를 원천봉쇄해. 각 테스트가 완전히 독립된 자신만의 공간에서 실행되니까, 다른 테스트가 무슨 짓을 하든 전혀 신경 쓸 필요가 없는 거지. 덕분에 우리는 훨씬 더 깔끔하고 예측 가능한 테스트를 작성할 수 있게 돼.

xUnit 테스트 실행 방식 MyTestClass new() Test1() new() Test2() 각 테스트마다 새 인스턴스 생성! 완벽한 격리 보장 기존 프레임워크 방식 MyTestClass (하나의 인스턴스) new() Test1() 실행 Test2() 실행 테스트 간 상태 공유 가능성 존재

xUnit vs NUnit vs MSTest: 뭐가 다른데?

C# 테스트 프레임워크 삼대장이라고 불리는 녀석들이지. 간단하게 비교해볼까?

  • MSTest: Microsoft가 만든 공식 프레임워크. Visual Studio랑 통합이 아주 찰떡궁합이야. 하지만 초기 버전은 기능이 좀 부족하고 확장성이 떨어져서 '어쩔 수 없이 쓰는' 느낌이 강했지. (물론 요즘은 많이 좋아졌어!)
  • NUnit: Java의 JUnit을 .NET으로 포팅하면서 시작된, 역사가 아주 깊은 프레임워크야. 기능도 풍부하고 커뮤니티도 활성화되어 있어서 오랫동안 C# 테스트의 표준처럼 쓰였어. 하지만 오래된 만큼 `[SetUp]`, `[TearDown]`, `[TestFixture]` 같은 조금은 구식의 어트리뷰트(Attribute)들을 사용해.
  • xUnit: 가장 현대적인 접근 방식을 채택했어. 위에서 말한 '테스트 격리'를 기본으로 하고, 불필요한 어트리뷰트를 대폭 줄였지. 예를 들어, NUnit의 `[Test]`는 xUnit에서 `[Fact]`로, `[TestCase]`는 `[Theory]`와 `[InlineData]`의 조합으로 훨씬 더 명확하고 강력하게 바뀌었어.

결론: 뭘 써야 할까?

정답은 없어. 팀의 문화나 프로젝트의 특성에 따라 선택하면 돼. 하지만 새로 시작하는 프로젝트라면, 혹은 더 깔끔하고 모던한 테스트 코드를 작성하고 싶다면 xUnit은 정말 매력적인 선택지야. 배우기 쉽고, 강력하고, 확장성도 뛰어나거든.

2. 실전! xUnit 프로젝트 세팅하기

백문이 불여일견! 이론은 이쯤하고, 직접 프로젝트를 만들면서 감을 잡아보자. Visual Studio를 켜고 나를 따라와 봐.

Step 1: 테스트 대상 프로젝트 만들기

먼저 우리가 테스트할 코드가 필요하겠지? 아주 간단한 계산기 클래스를 만들어 볼 거야.

1. Visual Studio에서 '새 프로젝트 만들기'를 선택해. 2. **'클래스 라이브러리(.NET)'** 템플릿을 선택하고, 프로젝트 이름은 `MyAwesomeCalculator`라고 지어주자. 3. `Class1.cs` 파일이 만들어지면, 이름을 `Calculator.cs`로 바꾸고 아래처럼 코드를 작성해.


namespace MyAwesomeCalculator
{
    public class Calculator
    {
        public int Add(int a, int b)
        {
            return a + b;
        }

        public int Subtract(int a, int b)
        {
            return a - b;
        }

        public int Multiply(int a, int b)
        {
            return a * b;
        }

        public double Divide(double a, double b)
        {
            if (b == 0)
            {
                throw new DivideByZeroException();
            }
            return a / b;
        }
    }
}
        

더하기, 빼기, 곱하기, 나누기 기능이 있는 초 심플한 계산기야. 특히 나누기 기능에는 0으로 나눌 때 예외를 던지는 로직을 넣어뒀어. 이따가 테스트할 때 아주 좋은 먹잇감이 될 거야.

Step 2: xUnit 테스트 프로젝트 추가하기

이제 이 계산기를 테스트할 프로젝트를 만들 차례야.

1. 솔루션 탐색기에서 솔루션을 마우스 오른쪽 버튼으로 클릭하고, **'추가' > '새 프로젝트'**를 선택해. 2. 검색창에 'xunit'이라고 입력하면 **'xUnit 테스트 프로젝트'**가 보일 거야. 이걸 선택! 3. 프로젝트 이름은 `MyAwesomeCalculator.Tests`처럼, 원래 프로젝트 이름 뒤에 `.Tests`를 붙이는 게 국룰이야. 이렇게 하면 딱 봐도 '아, 이건 테스트 프로젝트구나' 하고 알 수 있거든.

프로젝트를 만들고 나면, `xunit`과 `xunit.runner.visualstudio` 같은 NuGet 패키지가 자동으로 설치된 걸 볼 수 있을 거야.

  • xunit: xUnit 프레임워크의 핵심 라이브러리. `[Fact]`, `[Theory]`, `Assert` 같은 기능들이 여기 다 들어있어.
  • xunit.runner.visualstudio: Visual Studio의 '테스트 탐색기'에서 xUnit 테스트를 찾아내고 실행할 수 있게 해주는 어댑터야. 이게 없으면 VS가 우리 테스트를 인식하지 못해.

Step 3: 프로젝트 참조 추가하기

자, 이제 가장 중요한 단계 중 하나야. 테스트 프로젝트(`MyAwesomeCalculator.Tests`)가 계산기 프로젝트(`MyAwesomeCalculator`)의 코드를 알고 있어야 테스트를 할 수 있겠지? 이걸 **'프로젝트 참조'**를 추가한다고 해.

1. 솔루션 탐색기에서 `MyAwesomeCalculator.Tests` 프로젝트 아래의 **'종속성'**을 마우스 오른쪽 버튼으로 클릭해. 2. **'프로젝트 참조 추가'**를 선택. 3. 창이 뜨면 `MyAwesomeCalculator` 프로젝트를 체크하고 '확인'을 눌러줘.

이제 모든 준비는 끝났어! `MyAwesomeCalculator.Tests` 프로젝트는 `MyAwesomeCalculator`의 `public`으로 선언된 모든 클래스와 메소드에 접근할 수 있게 됐어.

3. 기본기 다지기: `[Fact]`와 `Assert`

드디어 코드를 짤 시간이야! 테스트 코드의 가장 기본이 되는 `[Fact]` 어트리뷰트와 `Assert` 클래스에 대해 알아보자.

`MyAwesomeCalculator.Tests` 프로젝트에 있는 `UnitTest1.cs` 파일의 이름을 `CalculatorTests.cs`로 바꾸고, 코드를 싹 지운 다음 아래처럼 새로 작성해봐.


using MyAwesomeCalculator; // 우리가 만든 계산기 프로젝트를 사용하겠다고 선언!
using Xunit;

namespace MyAwesomeCalculator.Tests
{
    public class CalculatorTests
    {
        [Fact]
        public void Add_TwoNumbers_ReturnsCorrectSum()
        {
            // 테스트 코드를 여기에 작성할 거야
        }
    }
}
        

`[Fact]`: 이건 반박불가 팩트!

`[Fact]`는 xUnit에게 "이 메소드는 하나의 테스트 케이스야!"라고 알려주는 꼬리표 같은 거야. 이 어트리뷰트가 붙은 메소드는 파라미터(입력값)가 없어야 하고, `void`를 반환해야 해 (비동기 테스트의 경우 `Task`도 가능).

`[Fact]`는 이름 그대로, **하나의 사실(Fact)을 검증**할 때 사용해. 예를 들면 "1 더하기 2는 반드시 3이어야 한다" 같은 거지.

AAA 패턴: 테스트 코드의 정석

테스트 코드를 짤 때는 전 세계 개발자들이 암묵적으로 따르는 패턴이 있어. 바로 **AAA (Arrange, Act, Assert)** 패턴이야.

  • Arrange (준비): 테스트에 필요한 모든 것을 준비하는 단계야. 객체를 생성하고, 필요한 데이터를 만들고, 모의(Mock) 객체를 설정하는 등의 작업을 해.
  • Act (실행): 실제로 테스트하고 싶은 메소드를 호출하는 단계야. 딱 한 줄의 코드로, 가장 핵심적인 부분이지.
  • Assert (검증): 실행 결과가 우리가 예상한 값과 일치하는지 확인하는 단계야. "그래서, 결과가 내가 생각한 대로 나왔어?"라고 묻는 과정이지.

이 패턴을 따르면 코드가 훨씬 깔끔해지고, 다른 사람이 봐도 "아, 이 테스트는 뭘 준비해서, 뭘 실행하고, 뭘 검증하는구나" 하고 한눈에 파악할 수 있어.

AAA 테스트 패턴 Arrange (준비) 테스트에 필요한 객체와 데이터를 세팅하는 단계 Act (실행) 테스트할 메소드를 호출하는 단계 Assert (검증) 실행 결과가 예상과 같은지 확인하는 단계

자, 그럼 아까 만든 `Add_TwoNumbers_ReturnsCorrectSum` 메소드를 AAA 패턴에 맞춰 완성해볼까?


[Fact]
public void Add_TwoNumbers_ReturnsCorrectSum()
{
    // Arrange (준비)
    var calculator = new Calculator(); // 테스트할 계산기 객체 생성
    int num1 = 5;
    int num2 = 10;
    int expected = 15; // 우리가 기대하는 결과값

    // Act (실행)
    int actual = calculator.Add(num1, num2); // 실제 메소드 호출

    // Assert (검증)
    Assert.Equal(expected, actual); // 실제 결과(actual)가 기대값(expected)과 같은지 확인
}
        

어때? 정말 간단하지? 코드만 봐도 "아, 계산기 객체를 만들어서 5랑 10을 더하면 15가 나오는지 확인하는 테스트구나" 하고 바로 이해가 되지.

이제 Visual Studio 메뉴에서 **'테스트' > '테스트 탐색기'**를 열어봐. 그럼 우리가 만든 `Add_TwoNumbers_ReturnsCorrectSum` 테스트가 목록에 보일 거야. 전체 테스트를 실행(▶▶ 아이콘)하거나, 해당 테스트에서 마우스 오른쪽 버튼을 눌러 '실행'을 클릭해봐. 잠시 후 테스트가 초록색 체크 표시와 함께 통과되는 걸 볼 수 있을 거야. 이 짜릿함! 이게 바로 테스트를 하는 맛이지.

`Assert` 클래스: 판결을 내리는 재판관

`Assert`는 테스트의 성공과 실패를 판가름하는 아주 중요한 녀석이야. `Assert.Equal()` 말고도 정말 다양한 검증 메소드를 제공해. 자주 쓰는 것들 몇 개만 알아볼까?

  • **Assert.Equal(expected, actual)**: 두 값이 같은지 확인. (가장 많이 씀)
  • **Assert.NotEqual(notExpected, actual)**: 두 값이 다른지 확인.
  • **Assert.True(condition)**: 조건이 `true`인지 확인. (예: `Assert.True(5 > 3);`)
  • **Assert.False(condition)**: 조건이 `false`인지 확인.
  • **Assert.Null(object)**: 객체가 `null`인지 확인.
  • **Assert.NotNull(object)**: 객체가 `null`이 아닌지 확인.
  • **Assert.Contains(expectedSubstring, actualString)**
    : 문자열 안에 특정 부분 문자열이 포함되어 있는지 확인.
  • **Assert.DoesNotContain(...)**: 포함되어 있지 않은지 확인.
  • **Assert.Empty(collection)**: 리스트나 배열 같은 컬렉션이 비어있는지 확인.
  • **Assert.NotEmpty(collection)**: 비어있지 않은지 확인.

가장 중요한 Assert.Throws

`Assert.Throws()`는 특정 예외(Exception)가 발생하는지를 검증하는, 정말 정말 중요한 메소드야. 우리 계산기의 `Divide` 메소드를 테스트해볼까?


[Fact]
public void Divide_ByZero_ThrowsDivideByZeroException()
{
    // Arrange
    var calculator = new Calculator();

    // Act & Assert
    // calculator.Divide(10, 0)을 실행했을 때,
    // DivideByZeroException 타입의 예외가 "반드시" 발생해야 이 테스트는 성공!
    Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
}
        

`Assert.Throws`는 조금 특이하게 생겼지? 람다식 `() => ...`을 사용해서 예외가 발생할 것으로 예상되는 코드를 감싸주는 형태야. 만약 저 코드 블록 안에서 `DivideByZeroException`이 발생하지 않거나, 다른 종류의 예외가 발생하면 테스트는 실패하게 돼. 이렇게 예외 상황까지 꼼꼼하게 테스트해야 코드의 안정성이 높아지는 거야.

4. 레벨업! 데이터 기반 테스트 `[Theory]`

`[Fact]`로 테스트의 기본을 익혔다면, 이제 슬슬 한계가 보이기 시작할 거야. 만약 `Add` 메소드를 다양한 숫자로 테스트하고 싶다면?

`[Fact]`만 사용한다면 이렇게 해야겠지.


[Fact] public void Add_1_and_2_is_3() { /* ... */ }
[Fact] public void Add_minus_5_and_10_is_5() { /* ... */ }
[Fact] public void Add_0_and_0_is_0() { /* ... */ }
// ... 끝도 없는 복사/붙여넣기 지옥
        

로직은 똑같은데 입력값과 기대값만 다른 테스트를 이렇게 여러 개 만드는 건 너무 비효율적이잖아. 이럴 때 구원투수처럼 등장하는 게 바로 `[Theory]` 어트리뷰트야.

`[Theory]`는 **하나의 테스트 로직을 여러 다른 데이터 셋으로 실행**할 수 있게 해주는, 데이터 기반 테스트(Data-Driven Test)를 위한 기능이야. `[Fact]`가 단발 소총이라면, `[Theory]`는 여러 발의 총알(데이터)을 장전해서 쏠 수 있는 반자동 소총 같은 거지.

`[InlineData]`: 가장 간단한 데이터 주입법

`[Theory]`와 함께 가장 많이 쓰이는 짝꿍은 `[InlineData]`야. 테스트 메소드에 필요한 파라미터를 직접 어트리뷰트에 넣어주는 방식이지. 백문이 불여일견, 코드를 보자.


[Theory]
[InlineData(1, 2, 3)]       // num1=1, num2=2, expected=3
[InlineData(-5, 10, 5)]     // num1=-5, num2=10, expected=5
[InlineData(0, 0, 0)]       // num1=0, num2=0, expected=0
[InlineData(-1, -1, -2)]    // num1=-1, num2=-1, expected=-2
public void Add_MultipleInputs_ReturnsCorrectSum(int num1, int num2, int expected)
{
    // Arrange
    var calculator = new Calculator();

    // Act
    int actual = calculator.Add(num1, num2);

    // Assert
    Assert.Equal(expected, actual);
}
        

와, 아까 `[Fact]`로 여러 개 만들려던 코드가 이렇게 깔끔하게 하나로 합쳐졌어!

`[Theory]`를 붙인 테스트 메소드는 파라미터를 가질 수 있어. 그리고 `[InlineData]`에 넣은 값들이 순서대로 이 파라미터에 쏙쏙 들어가게 돼. 테스트 탐색기를 보면 이 테스트가 `[InlineData]` 개수만큼, 즉 4개의 개별 테스트로 인식되고 각각 실행되는 걸 확인할 수 있을 거야. 경계값(0, 음수) 등을 테스트하기에 정말 최고지.

`[MemberData]`: 좀 더 복잡한 데이터를 위한 해결사

`[InlineData]`는 간단한 원시 타입(int, string 등) 데이터를 넣기엔 좋지만, 데이터가 복잡해지거나 다른 테스트와 데이터를 공유하고 싶을 땐 좀 불편해. 이럴 때 `[MemberData]`를 사용할 수 있어.

`[MemberData]`는 **테스트 클래스 안의 다른 멤버(속성이나 메소드)로부터 테스트 데이터를 가져오는 방식**이야. 데이터 소스는 `public static`이어야 하고, `IEnumerable` 타입을 반환해야 해.

예를 들어, `Subtract` 메소드를 테스트하기 위한 데이터를 `MemberData`로 만들어볼까?


public class CalculatorTests
{
    // ... 기존 Add 테스트 ...

    // 1. 테스트 데이터를 제공할 static 속성(Property) 정의
    public static IEnumerable<object[]> SubtractionTestData =>
        new List<object[]>
        {
            new object[] { 10, 5, 5 },
            new object[] { 5, 10, -5 },
            new object[] { 0, 0, 0 },
            new object[] { 100, -50, 150 }
        };

    [Theory]
    [MemberData(nameof(SubtractionTestData))] // 2. MemberData 어트리뷰트로 데이터 소스를 지정
    public void Subtract_MultipleInputs_ReturnsCorrectResult(int num1, int num2, int expected)
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var actual = calculator.Subtract(num1, num2);

        // Assert
        Assert.Equal(expected, actual);
    }
}
        

`nameof(SubtractionTestData)`는 문자열 `"SubtractionTestData"`와 똑같아. `nameof`를 쓰면 나중에 속성 이름을 바꿔도 컴파일러가 알아서 에러를 잡아주니까 훨씬 안전하고 좋아.

`MemberData`를 사용하면 `new` 키워드로 객체를 생성해서 넘겨주는 등, `InlineData`로는 불가능했던 훨씬 복잡하고 동적인 데이터 생성이 가능해져.

`[ClassData]`: 데이터 로직을 아예 분리하고 싶을 때

만약 테스트 데이터 생성 로직이 너무 복잡해서 별도의 파일로 관리하고 싶거나, 여러 테스트 클래스에서 공통으로 사용하고 싶다면? 그럴 땐 `[ClassData]`를 쓰면 돼.

`[ClassData]`는 `IEnumerable`를 구현하는 별도의 클래스를 만들어서 데이터를 공급하는 방식이야.

1. 먼저, 데이터 공급자 클래스를 만들어.


// MultiplicationTestData.cs
public class MultiplicationTestData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 5, 5, 25 };
        yield return new object[] { 10, 0, 0 };
        yield return new object[] { -7, 3, -21 };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
        

2. 그리고 테스트 메소드에서 이 클래스를 사용하면 돼.


// CalculatorTests.cs
[Theory]
[ClassData(typeof(MultiplicationTestData))]
public void Multiply_MultipleInputs_ReturnsCorrectResult(int num1, int num2, int expected)
{
    // Arrange
    var calculator = new Calculator();

    // Act
    var actual = calculator.Multiply(num1, num2);

    // Assert
    Assert.Equal(expected, actual);
}
        

이렇게 하면 테스트 코드와 데이터 생성 로직이 완벽하게 분리되어서, 코드가 훨씬 깔끔하고 재사용성도 높아져. 프로젝트가 커질수록 `ClassData`의 진가가 드러나지.

5. 테스트를 더 깔끔하게: Setup과 Teardown

테스트를 짜다 보면, 여러 테스트 메소드에서 반복적으로 수행해야 하는 작업들이 생겨. 예를 들어, 모든 테스트가 동일한 객체를 필요로 하거나, 테스트가 끝나고 나면 항상 파일을 지워야 하는 경우 같은 거지.

이런 공통적인 준비(Setup) 및 정리(Teardown) 작업을 효율적으로 처리하는 방법을 알아보자. 여기서 xUnit의 '테스트 격리' 철학이 다시 한번 빛을 발해.

기본 Setup/Teardown: 생성자와 `IDisposable`

NUnit이나 MSTest에서는 `[SetUp]`이나 `[TestInitialize]` 같은 어트리뷰트를 사용해서 각 테스트 메소드 실행 *전에* 호출될 코드를 지정해. 그리고 `[TearDown]`이나 `[TestCleanup]`으로 실행 *후에* 호출될 코드를 지정하지.

하지만 xUnit은 이런 어트리뷰트가 없어. 대신 훨씬 더 객체지향적인 방법을 사용해. 바로 **클래스의 생성자(Constructor)와 `IDisposable` 인터페이스**야.

기억나? xUnit은 모든 `[Fact]`나 `[Theory]` 케이스를 실행할 때마다 테스트 클래스의 새 인스턴스를 만든다고 했지. 바로 이 특징을 이용하는 거야.

  • 생성자: 각 테스트가 실행되기 직전에 새 인스턴스가 만들어지므로, 생성자에 있는 코드는 자연스럽게 **Setup** 역할을 하게 돼.
  • `IDisposable.Dispose()`: 각 테스트가 끝나면 인스턴스는 버려지는데, 만약 클래스가 `IDisposable`을 구현했다면, 버려지기 직전에 `Dispose()` 메소드가 호출돼. 이게 바로 **Teardown** 역할을 하는 거지.

코드로 보면 바로 이해될 거야.


public class CalculatorTestsWithSetup : IDisposable
{
    private readonly Calculator _calculator;

    // 생성자 (Setup)
    public CalculatorTestsWithSetup()
    {
        // 이 코드는 각 테스트 메소드가 실행되기 "전"에 매번 호출됨
        Console.WriteLine("Setup: Creating a new Calculator instance.");
        _calculator = new Calculator();
    }

    [Fact]
    public void Add_Test()
    {
        Console.WriteLine("Executing Add_Test...");
        var result = _calculator.Add(2, 3);
        Assert.Equal(5, result);
    }

    [Fact]
    public void Subtract_Test()
    {
        Console.WriteLine("Executing Subtract_Test...");
        var result = _calculator.Subtract(5, 3);
        Assert.Equal(2, result);
    }

    // Dispose 메소드 (Teardown)
    public void Dispose()
    {
        // 이 코드는 각 테스트 메소드가 실행된 "후"에 매번 호출됨
        // 여기서는 _calculator가 관리되지 않는 리소스를 사용하지 않으므로
        // 특별히 정리할 건 없지만, 예시를 위해 로그를 남김.
        Console.WriteLine("Teardown: Cleaning up resources.");
    }
}
        

이 테스트를 실행하고 출력 창을 보면, "Setup -> Executing Add_Test -> Teardown -> Setup -> Executing Subtract_Test -> Teardown" 순서로 로그가 찍히는 걸 볼 수 있을 거야. 각 테스트가 완벽하게 독립된 자신만의 `_calculator` 인스턴스를 가지고 시작하고, 끝나면 정리되는 거지. 이게 바로 xUnit 스타일!

`IClassFixture`: 비싼 자원 공유하기

그런데 만약 Setup 과정이 너무 오래 걸리거나 비용이 비싸다면 어떡할까? 예를 들어, 데이터베이스 연결을 설정하거나, 무거운 설정 파일을 읽어오는 작업 같은 거 말이야. 이런 걸 모든 테스트마다 반복하는 건 너무 낭비겠지.

이럴 때를 위해 xUnit은 **픽스처(Fixture)** 라는 개념을 제공해. `IClassFixture`를 사용하면, **하나의 테스트 클래스 안에 있는 모든 테스트들이 동일한 객체 인스턴스를 공유**할 수 있어.

1. 먼저, 공유할 자원을 관리하는 '픽스처' 클래스를 만들어.


// 이 클래스의 인스턴스가 테스트 클래스 내에서 공유될 거야.
public class DatabaseFixture : IDisposable
{
    public DbConnection Connection { get; private set; }

    public DatabaseFixture()
    {
        // 실제로는 DB 연결 코드가 들어가겠지.
        // 이 생성자는 클래스당 딱 한 번만 호출돼.
        Console.WriteLine("FIXTURE SETUP: Connecting to the database...");
        Connection = new DbConnection("MyConnectionString"); // 가상의 DB 연결 객체
    }

    public void Dispose()
    {
        // 모든 테스트가 끝나고 딱 한 번만 호출돼.
        Console.WriteLine("FIXTURE TEARDOWN: Disconnecting from the database...");
        Connection.Close();
    }
}

// 가상의 DB 연결 클래스
public class DbConnection {
    public DbConnection(string conn) {}
    public void Close() {}
}
        

2. 테스트 클래스에서 `IClassFixture`를 상속받고, 생성자를 통해 픽스처 인스턴스를 주입받아.


public class DatabaseTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    // 생성자를 통해 공유 픽스처 인스턴스를 받음
    public DatabaseTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void Test1_UsesSharedConnection()
    {
        // _fixture.Connection을 사용해서 테스트
        Console.WriteLine("Executing Test 1");
        Assert.NotNull(_fixture.Connection);
    }



    [Fact]
    public void Test2_UsesSameSharedConnection()
    {
        // Test1과 동일한 Connection 객체를 사용함
        Console.WriteLine("Executing Test 2");
        Assert.NotNull(_fixture.Connection);
    }
}
        

이 코드를 실행하면 출력 창에는 "FIXTURE SETUP -> Executing Test 1 -> Executing Test 2 -> FIXTURE TEARDOWN" 순서로 로그가 찍힐 거야. DB 연결(픽스처 생성)은 딱 한 번만 일어나고, `Test1`과 `Test2`가 그 연결을 공유해서 사용한 거지. 훨씬 효율적이지?

잠깐! 격리 원칙이 깨지는 거 아냐?

맞아. `IClassFixture`는 xUnit의 기본 철학인 '완전 격리'에서 한 발짝 물러선 기능이야. 하지만 이건 **상태를 변경하지 않는(immutable) 비싼 의존성**을 공유하기 위한 현실적인 타협점이지. 만약 테스트 A가 공유 픽스처의 상태를 변경하고, 테스트 B가 그 변경된 상태에 영향을 받는다면, 그건 픽스처를 잘못 사용하고 있는 거야. 공유 픽스처는 '읽기 전용'으로 사용한다고 생각하는 게 가장 좋아.

*참고: 여러 테스트 '클래스'에 걸쳐서 자원을 공유하고 싶다면 `ICollectionFixture`와 `[Collection]` 어트리뷰트를 사용하면 돼. 이건 더 큰 단위의 공유가 필요할 때 쓰는 기능이야.*

6. Mocking으로 의존성 격리하기

자, 이제 단위 테스트의 꽃이자, 많은 초보 개발자들이 어려워하는 주제인 **모킹(Mocking)**에 대해 이야기해볼 시간이야.

"내 코드는 완벽한데, 외부 API가 응답을 안 줘서 테스트가 실패했어요!", "DB에 테스트 데이터 넣기가 귀찮아서 테스트를 못 하겠어요." 이런 말, 해본 적 있거나 들어본 적 있지? 이런 문제들을 해결해주는 기술이 바로 모킹이야.

Mock이 대체 뭔데?

**Mock(모의 객체)**은 진짜 객체인 '척' 연기하는 가짜 객체야. 우리가 테스트하려는 코드(SUT, System Under Test)가 다른 객체(의존성)와 상호작용해야 할 때, 그 의존성을 진짜 대신 이 가짜 Mock 객체로 대체하는 거지.

왜 이런 짓을 하냐고?

  • 완벽한 격리: 내 코드의 로직만 테스트하고 싶어. 외부 API의 성능이나 DB의 상태에 따라 내 테스트 결과가 좌우되면 안 되잖아. Mock을 쓰면 외부 요소를 완벽하게 통제할 수 있어.
  • 속도: 실제 DB에 연결하거나 네트워크 통신을 하는 건 매우 느려. Mock 객체는 메모리에서만 동작하니까 번개처럼 빠르지. 테스트는 빨라야 제맛!
  • 예측 가능성: "만약 외부 API가 에러를 뱉는다면?" 같은 예외 상황을 테스트하기 쉬워져. 실제 API에서 에러를 일부러 만들 순 없지만, Mock 객체는 우리가 시키는 대로 에러를 뱉어주거든.
Mocking이 없을 때 내 코드 (SUT) 실제 데이터베이스 (느리고, 불안정) 외부 API (통제 불가능) Mocking을 사용할 때 내 코드 (SUT) Mock DB (빠르고, 안정적) Mock API (완벽 통제) 테스트의 대상을 '내 코드'에만 집중시킬 수 있다!

Moq 라이브러리로 실습하기

.NET 생태계에는 Mocking을 도와주는 여러 라이브러리가 있는데, 그중 가장 인기 있고 직관적인 게 바로 **Moq**([mɒk] "목"이라고 읽어)이야.

1. 먼저 테스트 프로젝트(`MyAwesomeCalculator.Tests`)에 Moq 패키지를 설치하자. NuGet 패키지 관리자에서 `Moq`를 검색해서 설치하면 끝.

2. 이제 Mocking을 실습하기 위한 시나리오를 만들어보자. 사용자 정보를 가져오는 `UserService`가 있고, 이 서비스는 데이터베이스에 접근하는 `IUserRepository`에 의존한다고 가정해볼게.


// --- MyAwesomeCalculator 프로젝트에 추가할 코드 ---

// 1. User 모델
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public bool IsPremium { get; set; }
}

// 2. 데이터베이스 접근을 위한 인터페이스 (이게 핵심!)
public interface IUserRepository
{
    User GetUserById(int id);
    void UpdateUser(User user);
}

// 3. 우리가 테스트하고 싶은 서비스 클래스
public class UserService
{
    private readonly IUserRepository _userRepository;

    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    public string GetUserGreeting(int id)
    {
        var user = _userRepository.GetUserById(id);

        if (user == null)
        {
            return "존재하지 않는 사용자입니다.";
        }

        if (user.IsPremium)
        {
            return $"환영합니다, {user.Name} 프리미엄 회원님!";
        }

        return $"안녕하세요, {user.Name}님.";
    }
}
        

여기서 중요한 건 `UserService`가 실제 `UserRepository` 클래스가 아니라, `IUserRepository` **인터페이스**에 의존한다는 점이야. 이렇게 인터페이스에 의존해야 테스트할 때 진짜 DB 클래스 대신 가짜 Mock 객체를 쉽게 쏙 끼워 넣을 수 있어. 이걸 **의존성 주입(Dependency Injection)**이라고 부르지.

자, 이제 `UserService`의 `GetUserGreeting` 메소드를 테스트하는 코드를 Moq를 사용해서 짜보자.


// --- MyAwesomeCalculator.Tests 프로젝트에 추가할 테스트 코드 ---
using Moq; // Moq 라이브러리 사용 선언

// ...

public class UserServiceTests
{
    [Fact]
    public void GetUserGreeting_PremiumUser_ReturnsPremiumGreeting()
    {
        // Arrange (준비)
        // 1. 가짜 User 객체 만들기
        var fakeUser = new User { Id = 1, Name = "재능이", IsPremium = true };

        // 2. IUserRepository의 Mock 객체 생성
        var mockRepo = new Mock<IUserRepository>();

        // 3. Mock 객체의 행동 설정: "GetUserById(1)이 호출되면, fakeUser를 반환해라"
        mockRepo.Setup(repo => repo.GetUserById(1)).Returns(fakeUser);

        // 4. Mock 객체를 주입하여 UserService 인스턴스 생성
        var userService = new UserService(mockRepo.Object);
        var expectedGreeting = "환영합니다, 재능이 프리미엄 회원님!";

        // Act (실행)
        var actualGreeting = userService.GetUserGreeting(1);

        // Assert (검증)
        Assert.Equal(expectedGreeting, actualGreeting);
    }

    [Fact]
    public void GetUserGreeting_UserNotFound_ReturnsNotFoundMessage()
    {
        // Arrange
        var mockRepo = new Mock<IUserRepository>();

        // 이번엔 GetUserById가 어떤 int 값이든 null을 반환하도록 설정
        mockRepo.Setup(repo => repo.GetUserById(It.IsAny<int>())).Returns((User)null);

        var userService = new UserService(mockRepo.Object);
        var expectedMessage = "존재하지 않는 사용자입니다.";

        // Act
        var actualMessage = userService.GetUserGreeting(999); // 존재하지 않는 ID

        // Assert
        Assert.Equal(expectedMessage, actualMessage);
    }
}
        

어때? `mockRepo.Setup(...)` 부분이 바로 Moq의 마법이야. 우리는 실제 데이터베이스 없이도, `IUserRepository`가 특정 상황에서 어떻게 행동할지를 완벽하게 시뮬레이션했어. 덕분에 `UserService`의 로직(프리미엄 회원인지, 사용자가 없는지 등)에만 집중해서 테스트할 수 있게 된 거지.

이런 고급 기술들은 혼자서 책만 보고 익히기엔 어려울 수 있어. 그럴 땐 **재능넷** 같은 재능 공유 플랫폼에서 C# 실무 경험이 풍부한 전문가 멘토를 찾아보는 것도 정말 좋은 방법이야. 실제 프로젝트에서 Mocking을 어떻게 활용하는지, 어떤 함정들이 있는지 같은 살아있는 꿀팁을 얻을 수 있거든.

7. 좋은 단위 테스트의 조건 (FIRST 원칙)

지금까지 xUnit을 사용하는 기술적인 방법들을 배웠어. 하지만 도구를 쓸 줄 아는 것과, 도구를 '잘' 쓰는 것은 다른 문제지. 좋은 단위 테스트는 어떤 특징을 가지고 있을까? 많은 개발자들이 **FIRST**라는 약자로 그 원칙을 이야기해.

좋은 단위 테스트를 위한 FIRST 원칙

Fast (빠르게), Independent (독립적으로), Repeatable (반복가능하게), Self-Validating (스스로 검증되게), Timely (시기적절하게)

F - Fast (빠르게)

단위 테스트는 빨라야 해. 정말, 정말 빨라야 해. 개발자는 코드를 수정할 때마다 수시로 테스트를 돌려보거든. 근데 테스트 실행하는 데 몇 분씩 걸린다면? 아무도 테스트를 돌리지 않게 될 거야. 그럼 테스트는 없는 거나 마찬가지가 되지. Mocking을 사용해서 외부 의존성을 제거하는 가장 큰 이유 중 하나도 바로 이 속도 때문이야.

I - Independent/Isolated (독립적으로/격리되어)

각각의 테스트는 서로에게 어떤 영향도 주어서는 안 돼. 테스트 A가 성공하든 실패하든, 테스트 B의 결과에는 아무런 영향을 미치지 않아야 한다는 거야. 테스트 실행 순서를 바꿔도 결과는 항상 같아야 해. xUnit이 각 테스트마다 새 인스턴스를 만드는 이유가 바로 이 원칙을 지키기 위해서지. 공유 상태를 만들 때는 `IClassFixture` 등을 사용하더라도 정말 신중해야 해.

R - Repeatable (반복가능하게)

테스트는 어떤 환경에서도(내 노트북, 동료의 PC, CI/CD 서버 등) 항상 동일한 결과를 내야 해. 만약 테스트가 "어쩔 땐 성공하고, 어쩔 땐 실패하는" 현상을 보인다면 그건 신뢰할 수 없는 테스트야. 보통 이런 문제는 외부 환경(현재 시간, 네트워크 상태, 특정 파일의 존재 여부 등)에 의존하는 코드를 Mocking하지 않았을 때 발생해.

S - Self-Validating (스스로 검증되게)

테스트는 실행 결과만으로 성공/실패를 명확하게 알려줘야 해. 테스트를 실행하고 나서, 개발자가 직접 로그 파일을 열어보거나 데이터베이스 값을 확인해야만 성공 여부를 알 수 있다면 그건 좋은 테스트가 아니야. `Assert` 구문을 사용해서 "기대하는 결과는 OOO인데, 실제 결과는 XXX이다"라고 코드가 스스로 판결을 내리도록 만들어야 해.

T - Timely (시기적절하게)

단위 테스트는 너무 늦지 않게, 가급적이면 프로덕션 코드를 작성하는 시점과 비슷하게 작성되어야 해. 가장 이상적인 건 **테스트 주도 개발(TDD, Test-Driven Development)**처럼 실패하는 테스트를 먼저 작성하고, 그 테스트를 통과시키는 코드를 작성하는 거야. 하지만 꼭 TDD가 아니더라도, 기능 개발이 끝난 직후에 바로 테스트를 작성하는 습관을 들이는 게 중요해. 나중에 한꺼번에 몰아서 짜려고 하면, 이미 로직은 다 잊어버렸고, 귀찮아서 결국 안 하게 되거든.

이 FIRST 원칙을 항상 마음속에 새기고 테스트 코드를 작성한다면, 너의 테스트는 프로젝트의 짐이 아니라, 든든한 자산이 될 거야.

마치며: 이제는 자신감 있게!

와, 정말 긴 여정이었어! xUnit이 뭔지 아무것도 모르던 상태에서 시작해서, `[Fact]`와 `[Theory]`로 기본기를 다지고, `IDisposable`과 `IClassFixture`로 테스트 구조를 잡고, 심지어 Mocking이라는 고급 기술까지 맛봤어. 이 정도면 어디 가서 "나 xUnit 좀 써봤다"고 말해도 될 정도야. 😉

단위 테스트는 처음에는 조금 어색하고 귀찮게 느껴질 수 있어. 하지만 한번 그 맛을 들이면, 테스트 없이는 코딩하는 게 불안해서 견딜 수 없게 될 거야. 테스트는 너의 코드가 의도대로 정확하게 동작한다는 것을 증명해주는 가장 확실한 문서이자, 미래의 내가(혹은 동료가) 코드를 망가뜨리지 않도록 지켜주는 최고의 안전장치야.

이제 너는 xUnit이라는 강력한 무기를 손에 넣었어. 버그에 대한 두려움 때문에 소심하게 코드를 고치던 시절은 잊어버려. 과감하게 리팩토링하고, 새로운 기능을 자신감 있게 추가해봐. 잘 짜인 테스트 스위트가 언제나 너의 뒤를 든든하게 받쳐줄 테니까.

물론 오늘 배운 게 전부는 아니야. 비동기 코드 테스트, UI 테스트, 통합 테스트 등 더 넓은 세계가 널 기다리고 있지. 끊임없이 배우고 성장하는 개발자가 되고 싶다면, **재능넷** 같은 플랫폼을 통해 새로운 지식을 탐색하고 전문가들과 교류하는 것도 잊지 마!

자, 이제 Visual Studio를 켜. 그리고 너의 프로젝트에 첫 번째 `[Fact]`를 추가해보는 거야. 버그 없는 클린 코드의 세계로 떠날 준비, 됐지? 화이팅!

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0