C# · 심층 가이드
C# 테스트 완전 정리
xUnit 스타일 단언부터 시계·저장소 목 객체, 비동기 테스트, 매개변수화까지 C# 테스트 코드를 직접 짜고 다듬는 15가지 실전 패턴을 정리했습니다.
C#의 테스트는 언어가 제공하는 정적 타입과 애트리뷰트 기반 발견 메커니즘 위에 올라갑니다. 테스트 러너가 리플렉션으로 메서드를 찾아 호출하기 때문에, 메서드 시그니처를 어떻게 선언하느냐가 곧 실행 방식이 됩니다. 여기서 초보자의 모델이 자주 깨집니다. 프로덕션 코드에서 async void가 위험하다는 건 알아도, 테스트에서 그것이 단순히 위험한 게 아니라 검증 자체를 무력화한다는 점은 겪어봐야 압니다.
읽는 순서는 단언에서 대역으로, 다시 구조로 넘어가는 흐름을 권합니다. 동등성 단언이 참조 비교와 값 비교를 어떻게 구분하는지 잡고 나면 컬렉션 테스트와 문자열 테스트가 왜 별도 단언을 쓰는지 자연스럽게 이어집니다. 그 다음 테이블 기반 테스트와 매개변수화 테스트로 입력 축을 늘리고, 경계값 테스트로 축의 양 끝을 채웁니다. 시계 목 객체와 저장소 목 객체는 그 입력 축에서 시간과 저장소를 뽑아내는 작업입니다.
함정 하나. 부동소수점을 Assert.Equal(expected, actual)로 비교하면 계산 경로가 조금만 달라져도 깨집니다. 허용 오차를 받는 오버로드를 쓰거나 아예 정수·decimal로 모델링하십시오. 또 하나, 목 객체로 DateTime.Now를 대체하지 않으면 자정 근처나 서머타임 전환에서만 실패하는 테스트가 생깁니다. 시계 목 객체 편을 먼저 읽고 시간을 인터페이스 뒤로 밀어 넣는 습관을 들이면 이후 비동기·통합 테스트가 훨씬 안정적으로 굴러갑니다.
01동등성 단언
1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.
C# code
// 동등성 단언
using System;
if (1 + 1 <= 1) throw new Exception("assert failed");
Console.WriteLine(true);실패 메시지에 기대값과 실제값을 함께 담으면 로그만 보고도 원인을 알 수 있습니다. 두 값을 보간해 넣은 문자열 하나면 충분합니다.
부동소수를 ==로 비교하면 계산 순서에 따른 미세한 오차 때문에 같은 값인데도 실패합니다. 허용 오차를 정해 차이의 절댓값으로 비교해야 합니다.
02테이블 기반 테스트
Stopwatch를 켜고 비어 있는 루프를 1000번 돈 뒤 경과 밀리초를 출력합니다. 여러 입력을 표로 만들어 돌릴 때 항목별 소요 시간을 이런 식으로 함께 찍어 볼 수 있습니다. 몸통이 빈 루프는 릴리스 빌드에서 통째로 사라질 수 있어 출력은 거의 항상 0이 됩니다.
C# code
// 테이블 기반 테스트
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);표의 각 행에 이름을 붙여 두면 실패했을 때 몇 번째 행이 깨졌는지 바로 드러나, 입력값을 하나하나 눈으로 대조하지 않아도 됩니다.
표의 항목을 하나의 테스트 안에서 반복 실행하면 첫 실패에서 멈춰 나머지 행의 결과를 볼 수 없습니다. 행마다 독립된 테스트로 나누는 편이 낫습니다.
03경계값 테스트
3 + 1이 3보다 크므로 조건이 성립하지 않아 그대로 통과합니다. 경계 근처 값을 넣어 보는 확인은 이렇게 상수를 바꿔 가며 반복합니다. int는 최댓값에서 1을 더하면 예외 없이 음수로 감싸 돌아가므로, 경계 검사에는 극단값도 함께 넣어 봐야 합니다.
C# code
// 경계값 테스트
using System;
if (3 + 1 <= 3) throw new Exception("assert failed");
Console.WriteLine(true);경계 검사에는 최솟값과 최댓값, 0, 빈 컬렉션, 원소가 하나뿐인 컬렉션을 함께 넣어 보면 하나 차이로 어긋나는 오류가 대부분 드러납니다.
int.MaxValue 근처를 시험하면서 1을 더한 값이 더 크다고 기대하면 오버플로로 거짓이 됩니다. 경계 근처 계산은 checked로 감싸 확인해야 합니다.
04시계 목 객체
Stopwatch.StartNew는 고해상도 타이머로 경과 시간만 재고 벽시계 시각과는 무관합니다. 시각에 따라 동작이 갈리는 코드를 시험하려면 현재 시각을 인터페이스로 주입해 원하는 값을 돌려주게 만듭니다. DateTime.Now를 직접 부르면 테스트에서 시각을 고정할 수 없고 시간대와 서머타임에도 흔들립니다.
C# code
// 시계 목 객체
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);.NET에는 시간을 추상화한 TimeProvider가 있어, 테스트에서 현재 시각과 타이머 진행을 원하는 대로 조작할 수 있습니다.
DateTime.Now는 서머타임 전환 구간에 존재하지 않거나 두 번 나타나는 시각을 돌려줄 수 있습니다. 시간 차 계산은 UTC를 기준으로 두어야 합니다.
05저장소 목 객체
조건이 거짓이라 예외를 건너뛰고 끝나는, 외부 의존이 전혀 없는 확인입니다. 데이터베이스를 쓰는 코드도 저장소를 인터페이스로 받게 만들면 이 정도로 단순한 테스트가 됩니다. 가짜 저장소가 IEnumerable을 돌려주면 대상 코드가 두 번 순회할 때 결과가 달라질 수 있으니 리스트로 확정해 반환합니다.
C# code
// 저장소 목 객체
using System;
if (5 + 1 <= 5) throw new Exception("assert failed");
Console.WriteLine(true);목 객체가 받은 인자를 기록해 두면 무엇을 저장했는지까지 검증할 수 있지만, 호출 순서와 횟수까지 전부 검증하면 구현을 조금만 바꿔도 테스트가 깨집니다.
가짜 저장소가 매번 같은 인스턴스를 돌려주면 테스트에서 그 객체를 고쳤을 때 다음 조회 결과까지 바뀌어, 실제 저장소와 다른 동작을 검증하게 됩니다.
06비동기 테스트
동기 코드의 실행 시간을 재는 형태이고 await는 들어 있지 않습니다. 비동기 메서드를 시험할 때는 테스트 메서드를 async Task로 선언하고 결과를 await해야 합니다. async void로 두면 러너가 완료를 기다리지 못해 안에서 난 예외가 그대로 사라집니다.
C# code
// 비동기 테스트
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);비동기 테스트에 시간 제한이 필요하다면 러너의 타임아웃 설정이나 WaitAsync를 쓰는 편이, 무한 대기로 전체 실행이 멈추는 것보다 낫습니다.
테스트 안에서 .Result나 .Wait()를 쓰면 러너의 컨텍스트에 따라 교착이 생기고, 실패가 아니라 영원한 멈춤으로 나타나 원인을 찾기 어렵습니다.
07예외 테스트
조건이 참일 때만 Exception을 던지도록 되어 있는데, 여기서는 참이 되지 않아 아무 일도 일어나지 않습니다. 예외가 나야 정상인 경로를 검증하려면 반대로 호출을 감싸고 예외가 오지 않을 때 실패로 처리합니다. catch (Exception)처럼 넓게 잡으면 의도하지 않은 NullReferenceException까지 통과시키므로 타입을 좁혀야 합니다.
C# code
// 예외 테스트
using System;
if (7 + 1 <= 7) throw new Exception("assert failed");
Console.WriteLine(true);예외를 검증할 때는 타입뿐 아니라 ParamName이나 메시지의 핵심 부분까지 확인하면, 다른 이유로 같은 타입이 나온 경우를 걸러 낼 수 있습니다.
예외를 기대하는 테스트에서 여러 줄을 함께 감싸면, 의도한 줄이 아니라 앞선 줄에서 난 예외로도 테스트가 통과해 버립니다.
08속성 스타일 테스트
고정된 횟수만큼 루프를 돌리고 걸린 시간을 재는 코드입니다. 속성 기반 검증은 이 반복 자리에 무작위 입력을 넣고, 어떤 값이 와도 성립해야 하는 규칙을 확인합니다. 실패한 입력을 다시 재현하려면 난수 시드를 로그에 남겨야 하고, 남기지 않으면 같은 실패를 두 번 볼 수 없습니다.
C# code
// 속성 스타일 테스트
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);속성 기반 검증은 결과를 직접 계산해 비교하는 대신, 정렬 결과의 길이가 원본과 같다는 식의 항상 성립해야 하는 관계를 확인하는 편이 만들기 쉽습니다.
난수를 시드 없이 만들면 실행마다 입력이 달라져, 어제 실패한 테스트가 오늘은 통과합니다. 시드를 기록하지 않으면 그 실패를 다시 재현할 수 없습니다.
09스냅샷 스타일 테스트
확인이 조건식 하나로 코드에 박혀 있어 기대값이 소스와 함께 관리됩니다. 출력이 길어지면 기대 결과를 파일로 빼 두고 실행 결과와 통째로 비교하는 방식으로 바꿉니다. 스냅샷은 갱신이 쉬운 만큼 잘못된 결과도 그대로 굳을 수 있어, 갱신할 때 차이를 눈으로 확인해야 합니다.
C# code
// 스냅샷 스타일 테스트
using System;
if (9 + 1 <= 9) throw new Exception("assert failed");
Console.WriteLine(true);스냅샷에는 실행 시각이나 자동 생성 식별자처럼 매번 달라지는 값이 들어가지 않도록 미리 정규화해 두어야 안정적으로 비교할 수 있습니다.
실패했을 때 습관적으로 스냅샷을 갱신하면 버그가 새 기준값으로 굳어집니다. 갱신 커밋은 차이를 반드시 함께 검토해야 합니다.
10통합 테스트 스케치
타이머를 켜고 작업을 돌린 뒤 멈추는 이 흐름이 통합 테스트의 뼈대와 같습니다. 실제로는 루프 자리에 서버 기동과 요청 호출이 들어가고, 끝나면 자원을 정리합니다. 통합 테스트는 병렬로 돌릴 때 같은 포트나 같은 데이터베이스를 두고 부딪히기 쉬워 테스트마다 격리 수단을 정해 두어야 합니다.
C# code
// 통합 테스트 스케치
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);웹 애플리케이션이라면 실제 포트를 열지 않고 메모리 안에서 요청을 처리하는 테스트 호스트를 쓰면, 병렬 실행에서도 포트 충돌이 생기지 않습니다.
테스트가 남긴 데이터를 지우지 않으면 다음 실행에서 개수나 중복 검사가 어긋납니다. 정리는 끝날 때만이 아니라 시작 시점에도 함께 해 두는 편이 안전합니다.
11파서 테스트
덧셈 결과를 부등호로 비교해 참이면 통과시키는 구조라, 입력과 기대값이 한 줄에 같이 있습니다. 파서라면 이 자리에 문자열을 넣고 나온 값이 맞는지 비교합니다. 잘못된 입력에 대해 int.Parse는 예외를 던지고 int.TryParse는 false를 돌려주므로, 어느 쪽을 쓰느냐에 따라 테스트 작성법이 달라집니다.
C# code
// 파서 테스트
using System;
if (11 + 1 <= 11) throw new Exception("assert failed");
Console.WriteLine(true);파서를 시험할 때는 정상 입력만이 아니라 빈 문자열, 공백뿐인 문자열, 아주 긴 입력, 앞에 0이 붙은 숫자처럼 경계에 있는 값을 함께 넣어야 합니다.
기대값을 파서 자신으로 만들어 비교하면 잘못된 구현이 스스로를 통과시킵니다. 기대값은 손으로 적거나 다른 방법으로 계산해야 합니다.
12컬렉션 테스트
1000회 반복의 경과 시간을 출력하는 코드로 컬렉션 비교는 들어 있지 않습니다. 목록을 검증할 때는 개수만 보지 말고 원소와 순서를 함께 확인해야 합니다. Equals로 두 리스트를 비교하면 내용이 같아도 참조가 달라 항상 거짓이 나오므로 SequenceEqual을 씁니다.
C# code
// 컬렉션 테스트
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);순서가 중요하지 않은 결과라면 양쪽을 정렬해 비교하거나 집합으로 만들어 비교해야, 구현이 순서를 바꿨을 때 테스트가 불필요하게 깨지지 않습니다.
SequenceEqual은 원소의 Equals로 비교하므로, 값 비교를 구현하지 않은 클래스 목록에서는 내용이 같아도 항상 거짓이 나옵니다.
13문자열 테스트
숫자 비교 한 줄로 통과 여부를 가리는 형태이고 문자열은 등장하지 않습니다. 문자열을 검증할 때는 비교 방식을 명시해 두는 편이 안전합니다. ==는 서수 비교라 정규화 형태가 다른 같은 글자를 서로 다르게 보고, 대소문자를 무시하려면 string.Equals에 비교자를 넘겨야 합니다.
C# code
// 문자열 테스트
using System;
if (13 + 1 <= 13) throw new Exception("assert failed");
Console.WriteLine(true);여러 줄 문자열을 비교할 때는 개행 문자를 미리 통일해 두어야, 윈도우와 리눅스에서 같은 테스트가 서로 다른 결과를 내지 않습니다.
실패 메시지에 문자열을 그대로 찍으면 공백과 개행의 차이가 눈에 보이지 않습니다. 어긋난 위치나 길이를 함께 남겨야 원인을 짚을 수 있습니다.
14매개변수화 테스트
반복 횟수 1000이 코드에 직접 박혀 있어 다른 값으로 돌리려면 소스를 고쳐야 합니다. 입력을 인자로 빼면 같은 본문으로 여러 조건을 돌릴 수 있습니다. xUnit에서는 [Theory]와 [InlineData]가 이 역할을 하는데, 인자는 컴파일 타임 상수여야 하므로 계산된 값은 [MemberData]로 넘깁니다.
C# code
// 매개변수화 테스트
using System;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
for (var i = 0; i < 1000; i++) {}
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);[MemberData]는 정적 속성이나 메서드에서 데이터를 가져오므로, 파일에서 읽거나 계산해 만든 입력 집합도 그대로 넘길 수 있습니다.
케이스 하나가 다른 케이스가 남긴 정적 상태에 의존하면, 순서를 바꾸거나 병렬로 돌릴 때만 실패하는 재현하기 어려운 문제가 됩니다.
15간단한 벤치마크
이 코드에는 시간 측정이 없고 조건 검사만 있습니다. 성능을 확인하려면 대상 호출을 여러 번 반복해 총시간을 나누고, 측정 전에 몇 번 미리 돌려 JIT 컴파일을 끝내 두어야 합니다. 디버그 빌드에서 잰 수치는 최적화가 꺼져 있어 실제 배포본과 비교할 수 없습니다.
C# code
// 간단한 벤치마크
using System;
if (15 + 1 <= 15) throw new Exception("assert failed");
Console.WriteLine(true);측정한 결과를 어딘가에 누적해 실제로 사용해야 JIT가 계산 자체를 없애지 않습니다. 반환값을 버리는 벤치마크는 아무것도 재지 못할 수 있습니다.
한 번만 측정한 값으로 판단하면 GC가 끼어든 실행인지 아직 예열 중인 실행인지 구분할 수 없습니다. 여러 번 재고 분포를 봐야 합니다.
정리하며
- 테스트 메서드는 async void 대신 async Task로 선언해야 실패가 러너에 전달됩니다
- 부동소수점 단언은 허용 오차 오버로드를 쓰거나 decimal로 모델링해 회피합니다
- 현재 시각과 저장소는 인터페이스 뒤로 밀어내야 재현 가능한 테스트가 됩니다
- 입력 조합이 늘어나면 중복 메서드 대신 테이블·매개변수화 방식으로 축을 정리합니다
더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.