C# · 심화
제네릭·비동기·성능 설계
테스트하기 좋은 설계 - 경계·가짜 객체·시간 추상화
인터페이스 경계, 테스트 더블 직접 작성, TimeProvider, xUnit 테스트 예시(Tests/ 폴더)
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 생성자 주입으로 객체가 필요한 협력자를 밖에서 받는 방법과 수명 관리를 다뤘다. 이 장은 같은 구조를 테스트 쪽에서 활용한다. 배송 지연 알림 기능을 예로 들어, 시계·저장소·알림 발송처럼 바깥 세계와 닿는 부분을 인터페이스 뒤로 옮긴다. 그 자리에 직접 만든 가짜 객체를 꽂아 결과가 항상 같은 테스트를 만든다.
- 바깥 세계와 닿는 지점을 찾아 인터페이스 경계로 분리한다.
- 스텁·스파이·페이크를 외부 라이브러리 없이 직접 작성한다.
TimeProvider로 시간 의존을 걷어내고, 테스트에서 시계를 원하는 만큼 앞으로 돌린다.- xUnit 테스트를
Tests/폴더에 배치하는 방법과 프로젝트 구성상의 함정을 안다.
문제 상황
물류 센터 서비스에 요구가 하나 들어왔다. 접수 후 24시간이 지나도 배송이 시작되지 않은 소포는 수취인에게 지연 안내를 보내야 한다. 첫 구현은 다음과 같았다.
public sealed class DelayChecker
{
public int NotifyDelayed()
{
int count = 0;
foreach (Parcel p in SqlParcelStore.ListReceived()) // 실제 DB
{
if (DateTime.UtcNow - p.ReceivedAt > TimeSpan.FromHours(24)) // 실제 시계
{
SmsClient.Send(p.Recipient, "배송이 지연되고 있습니다."); // 실제 발송
count++;
}
}
return count;
}
}
논리는 단순하지만 테스트할 방법이 마땅치 않다. 23시간 59분과 24시간 1분의 경계를 확인하려면 하루를 기다리거나 DB의 접수 시각을 조작해야 한다. 테스트를 돌릴 때마다 수취인에게 실제 문자가 나갈 수도 있다. Thread.Sleep 으로 기다리는 방식은 느릴 뿐 아니라, 기다린 시간이 판정 기준과 미묘하게 어긋나 실행마다 결과가 달라진다.
원인은 판정 논리가 세 가지 외부 사실(저장된 소포, 현재 시각, 발송 결과)에 직접 손을 뻗고 있다는 점이다. 논리는 그대로 두고 이 셋을 밖에서 받도록 바꾸면 된다.
경계를 인터페이스로 자르기
어디에 경계를 둘까
경계를 둘 곳은 코드가 통제할 수 없거나 실행마다 값이 달라지는 것과 만나는 지점이다. 파일 시스템, 데이터베이스, 네트워크, 현재 시각, 난수가 여기에 속한다. 반대로 문자열 조립이나 두 시각의 차 계산처럼 입력이 같으면 출력도 같은 순수한 계산은 경계로 분리하지 않고 그대로 테스트한다.
| 의존 | 배송 서비스의 예 | 추상화 | 테스트용 대체 |
|---|---|---|---|
| 저장소 | 접수된 소포 조회·저장 | IParcelStore | 메모리 딕셔너리 |
| 발송 | 수취인 안내 발송 | INotifier | 보낸 내용을 기록하는 객체 |
| 시간 | 접수 후 경과 시간 판정 | TimeProvider | 직접 움직이는 시계 |
| 실패 상황 | 발송 서버 연결 끊김 | INotifier | 항상 예외를 던지는 객체 |
인터페이스는 호출하는 쪽이 필요한 만큼만
인터페이스의 모양은 구현체가 아니라 사용하는 쪽이 정한다. DelayChecker 가 필요한 것은 "접수 상태 소포 목록", "저장", "조회" 세 가지다. 그래서 IParcelStore 는 메서드가 세 개뿐이다. 삭제나 페이지 조회까지 미리 넣으면 테스트용 가짜 객체도 그만큼 구현해야 하고, 인터페이스가 바뀔 때 고칠 곳이 늘어난다.
협력자는 앞 장에서 다룬 대로 생성자로 받는다. 필드에 readonly 로 붙들어 두면 객체가 만들어진 뒤에는 협력자가 바뀌지 않는다. 운영에서는 진짜 구현을, 테스트에서는 가짜 구현을 넘기는 곳이 조립 지점이며, 판정 논리 안에는 어느 쪽인지 드러나는 코드가 없다.
테스트 더블과 TimeProvider
테스트 더블 직접 쓰기
테스트에서 진짜 협력자를 대신하는 객체를 통틀어 테스트 더블(test double)이라 한다. 역할에 따라 부르는 이름이 다르다. 모킹 라이브러리 없이도 클래스 몇 줄이면 충분하다.
| 종류 | 하는 일 | 이 장의 예 | 확인하는 것 |
|---|---|---|---|
| 스텁(stub) | 정해 둔 값이나 예외를 돌려준다 | ThrowingNotifier | 실패 상황에서의 동작 |
| 스파이(spy) | 받은 호출을 기록한다 | SpyNotifier | 무엇이 발송됐는가 |
| 페이크(fake) | 단순하지만 실제로 동작하는 대체 구현 | InMemoryParcelStore | 저장 후 상태 |
| 조작 가능한 시계 | 시각을 테스트가 정한다 | FakeClock | 시간 경과에 따른 판정 |
구분은 분류 자체가 목적이 아니라 검증 방법을 고르는 기준이다. 스파이는 "무엇이 나갔는가"를, 페이크는 "결과 상태가 어떤가"를 묻는 데 쓴다. 가능하면 호출 횟수보다 결과 상태를 검증하는 편이 구현이 바뀌어도 잘 깨지지 않는다.
시간은 TimeProvider 로
DateTime.UtcNow 는 정적 멤버라 바꿔 끼울 수 없다. .NET 8부터 BCL에 들어온 TimeProvider 는 이 문제를 위한 추상 클래스다. 운영에서는 TimeProvider.System 을 쓰고, 현재 시각은 GetUtcNow() 로 읽는다. 테스트에서는 이 클래스를 상속해 GetUtcNow() 를 재정의하면 된다. 공식 문서는 TimeProvider 클래스 항목에 있다.
Microsoft 가 별도 NuGet 패키지로 제공하는 테스트용 시계도 있다. 이 책은 BCL만 쓰므로 직접 만든다. 구현은 현재 시각 필드 하나와 Advance 메서드가 전부다.
이 시계는 GetUtcNow() 만 재정의한다. 타이머 생성이나 Task.Delay 에 넘기는 용도까지 제어하려면 CreateTimer 등도 다뤄야 하는데, 이 장의 판정 논리는 현재 시각만 읽으므로 거기까지는 가지 않는다. 지역 시간이 필요한 코드라면 LocalTimeZone 도 고정해야 한다. 이 내용은 연습 문제에서 다룬다.
xUnit 테스트를 Tests/ 폴더에 두기
실무에서는 테스트 실행기로 xUnit 같은 프레임워크를 쓴다. xUnit 은 NuGet 패키지이므로 이 책의 실행 환경 조건(BCL만 사용)에 맞는 완성 코드에는 넣지 않았다. 아래 안내는 동일한 검증을 별도 프로젝트에 옮기는 방법이며, 이 장의 dotnet run 검증 범위 밖이다. 완성 코드의 MiniRunner 는 프레임워크가 하는 일 중 "실행하고 통과·실패를 센다"만 흉내 낸 것이다.
프로젝트 배치는 다음과 같다.
ParcelApp/
ParcelApp.csproj
Program.cs
Tests/
Tests.csproj
DelayCheckerTests.cs
주의할 점이 둘 있다. 첫째, SDK 스타일 프로젝트는 하위 폴더의 .cs 파일을 전부 컴파일에 넣는다. 앱 프로젝트가 Tests/ 안의 xUnit 코드를 함께 컴파일하려다 Xunit 네임스페이스를 못 찾아 실패하므로 제외해야 한다. 둘째, 앱의 타입은 기본이 internal 이라 테스트 프로젝트에서 보이지 않는다. 아래 항목으로 공개 범위를 열어 준다.
<!-- ParcelApp.csproj 에 추가 -->
<ItemGroup>
<Compile Remove="Tests/**" />
<InternalsVisibleTo Include="Tests" />
</ItemGroup>
테스트 프로젝트는 dotnet new xunit -o Tests 로 만들고 dotnet add Tests reference ParcelApp.csproj 로 앱을 참조한다. 패키지 버전은 템플릿이 채운다. 테스트는 다음과 같은 모양이 된다. 경계값을 Theory 하나로 묶을 수 있는 것이 프레임워크의 이점이다.
using Xunit;
public class DelayCheckerTests
{
[Theory]
[InlineData(23, 0)]
[InlineData(24, 0)]
[InlineData(25, 1)]
public void 경과_시간에_따라_알림_수가_달라진다(int hours, int expected)
{
var scene = new Scene();
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(hours));
int count = scene.Checker.NotifyDelayed(TimeSpan.FromHours(24));
Assert.Equal(expected, count);
}
}
완성 코드
아래는 Program.cs 한 파일이다. .NET 10 콘솔 템플릿의 기본 설정(암시적 using, nullable 활성화)을 전제로 하며, 타입은 최상위 문 뒤에 선언한다.
var runner = new MiniRunner();
TimeSpan limit = TimeSpan.FromHours(24);
runner.Run("한도 안의 소포는 알리지 않는다", () =>
{
var scene = new Scene();
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(23));
int count = scene.Checker.NotifyDelayed(limit);
Check.Equal(0, count);
Check.Equal(0, scene.Notifier.Sent.Count);
});
runner.Run("정확히 한도 시각이면 아직 지연이 아니다", () =>
{
var scene = new Scene();
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(24));
Check.Equal(0, scene.Checker.NotifyDelayed(limit));
});
runner.Run("한도를 넘기면 알리고 Delayed 로 바꾼다", () =>
{
var scene = new Scene();
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(25));
int count = scene.Checker.NotifyDelayed(limit);
Check.Equal(1, count);
Check.Equal("김하늘", scene.Notifier.Sent[0].Recipient);
Check.True(scene.Notifier.Sent[0].Message.Contains("P-100"), "메시지에 운송장 번호가 있어야 한다");
Check.True(scene.Store.Find("P-100") is { Status: ParcelStatus.Delayed }, "상태가 Delayed 여야 한다");
});
runner.Run("같은 소포를 두 번 알리지 않는다", () =>
{
var scene = new Scene();
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(25));
int first = scene.Checker.NotifyDelayed(limit);
int second = scene.Checker.NotifyDelayed(limit);
Check.Equal(1, first);
Check.Equal(0, second);
Check.Equal(1, scene.Notifier.Sent.Count);
});
runner.Run("배송 완료 소포는 건너뛴다", () =>
{
var scene = new Scene();
scene.Store.Save(new Parcel("P-200", "박민준", scene.Clock.GetUtcNow(), ParcelStatus.Delivered));
scene.Clock.Advance(TimeSpan.FromHours(100));
Check.Equal(0, scene.Checker.NotifyDelayed(limit));
});
runner.Run("알림이 실패하면 상태를 바꾸지 않는다", () =>
{
var clock = new FakeClock(Scene.Start);
var store = new InMemoryParcelStore();
store.Save(new Parcel("P-100", "김하늘", clock.GetUtcNow(), ParcelStatus.Received));
var checker = new DelayChecker(store, new ThrowingNotifier(), clock);
clock.Advance(TimeSpan.FromHours(25));
int count = checker.NotifyDelayed(limit);
Check.Equal(0, count);
Check.True(store.Find("P-100") is { Status: ParcelStatus.Received }, "상태가 Received 로 남아야 한다");
});
runner.PrintSummary();
TimeProvider real = TimeProvider.System;
var liveStore = new InMemoryParcelStore();
liveStore.Save(new Parcel("P-900", "이한솔", real.GetUtcNow().AddHours(-30), ParcelStatus.Received));
var live = new DelayChecker(liveStore, new ConsoleNotifier(), real);
Console.WriteLine($"운영 조립 결과: {live.NotifyDelayed(limit)}건 알림");
enum ParcelStatus { Received, Delayed, Delivered }
record Parcel(string Id, string Recipient, DateTimeOffset ReceivedAt, ParcelStatus Status);
interface IParcelStore
{
IReadOnlyList<Parcel> ListByStatus(ParcelStatus status);
void Save(Parcel parcel);
Parcel? Find(string id);
}
interface INotifier
{
void Send(string recipient, string message);
}
sealed class DelayChecker
{
private readonly IParcelStore _store;
private readonly INotifier _notifier;
private readonly TimeProvider _time;
public DelayChecker(IParcelStore store, INotifier notifier, TimeProvider time)
{
_store = store;
_notifier = notifier;
_time = time;
}
public int NotifyDelayed(TimeSpan limit)
{
DateTimeOffset now = _time.GetUtcNow();
int notified = 0;
foreach (Parcel parcel in _store.ListByStatus(ParcelStatus.Received))
{
if (now - parcel.ReceivedAt <= limit)
{
continue;
}
try
{
_notifier.Send(parcel.Recipient, $"소포 {parcel.Id} 배송이 지연되고 있습니다.");
}
catch (InvalidOperationException)
{
continue;
}
_store.Save(parcel with { Status = ParcelStatus.Delayed });
notified++;
}
return notified;
}
}
sealed class InMemoryParcelStore : IParcelStore
{
private readonly Dictionary<string, Parcel> _items = new();
public IReadOnlyList<Parcel> ListByStatus(ParcelStatus status) =>
_items.Values
.Where(p => p.Status == status)
.OrderBy(p => p.Id, StringComparer.Ordinal)
.ToList();
public void Save(Parcel parcel) => _items[parcel.Id] = parcel;
public Parcel? Find(string id) => _items.TryGetValue(id, out Parcel? found) ? found : null;
}
sealed class SpyNotifier : INotifier
{
public List<(string Recipient, string Message)> Sent { get; } = new();
public void Send(string recipient, string message) => Sent.Add((recipient, message));
}
sealed class ThrowingNotifier : INotifier
{
public void Send(string recipient, string message) =>
throw new InvalidOperationException("발송 서버 연결 실패");
}
sealed class ConsoleNotifier : INotifier
{
public void Send(string recipient, string message) =>
Console.WriteLine($"[알림] {recipient}: {message}");
}
sealed class FakeClock : TimeProvider
{
private DateTimeOffset _now;
public FakeClock(DateTimeOffset start) => _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
sealed class Scene
{
public static readonly DateTimeOffset Start = new(2026, 3, 2, 9, 0, 0, TimeSpan.Zero);
public FakeClock Clock { get; } = new(Start);
public InMemoryParcelStore Store { get; } = new();
public SpyNotifier Notifier { get; } = new();
public DelayChecker Checker { get; }
public Scene() => Checker = new DelayChecker(Store, Notifier, Clock);
public void Receive(string id, string recipient) =>
Store.Save(new Parcel(id, recipient, Clock.GetUtcNow(), ParcelStatus.Received));
}
sealed class CheckFailedException(string message) : Exception(message);
static class Check
{
public static void Equal<T>(T expected, T actual)
{
if (!EqualityComparer<T>.Default.Equals(expected, actual))
{
throw new CheckFailedException($"기대 {expected}, 실제 {actual}");
}
}
public static void True(bool condition, string what)
{
if (!condition)
{
throw new CheckFailedException(what);
}
}
}
sealed class MiniRunner
{
private int _passed;
private int _failed;
public void Run(string name, Action test)
{
try
{
test();
_passed++;
Console.WriteLine($"통과 {name}");
}
catch (CheckFailedException e)
{
_failed++;
Console.WriteLine($"실패 {name} - {e.Message}");
}
}
public void PrintSummary() => Console.WriteLine($"통과 {_passed}, 실패 {_failed}");
}
줄별 해설
도메인 타입. Parcel 은 record 이므로 with 식으로 상태만 바꾼 새 값을 만든다. 저장소에 넣은 객체를 변경하지 않으니, 목록을 돌면서 저장해도 이미 꺼낸 값이 흔들리지 않는다.
인터페이스. IParcelStore 와 INotifier 는 판정에 필요한 메서드만 갖는다. 시간은 새 인터페이스를 만들지 않고 BCL의 TimeProvider 를 그대로 받는다.
DelayChecker. 생성자가 세 협력자를 받아 readonly 필드에 둔다. NotifyDelayed 는 시각을 한 번만 읽는다. 목록을 돌며 매번 읽으면 처리 도중 기준이 움직일 수 있기 때문이다. 비교는 <= 이므로 정확히 한도 시각인 소포는 지연이 아니다. 발송에 성공한 뒤에만 Delayed 로 저장하므로, 발송이 실패한 소포는 Received 로 남아 다음 실행에서 다시 대상이 된다. 목록은 저장소가 복사본으로 돌려주기 때문에 순회 중 저장해도 컬렉션이 변경됐다는 예외가 나지 않는다.
테스트 더블. InMemoryParcelStore 는 목록을 ID 순서로 정렬해 돌려준다. 딕셔너리 순회 순서에 기대지 않으므로 발송 순서가 항상 같다. SpyNotifier 는 튜플 목록에 발송 내용을 쌓는다. ThrowingNotifier 는 항상 예외를 던진다. FakeClock 은 GetUtcNow() 를 재정의하고 Advance 로 필드만 옮긴다.
Scene. 테스트마다 반복되는 준비(시계, 저장소, 스파이, 판정기 조립)를 묶었다. Start 를 고정 날짜로 두었기 때문에 실행 날짜와 무관하게 결과가 같다. 조립 방식이 다른 마지막 테스트는 Scene 을 쓰지 않고 직접 조립한다.
Check 와 MiniRunner. Check 는 조건이 어긋나면 CheckFailedException 을 던지고, MiniRunner.Run 은 이를 잡아 실패로 센다. 예상하지 못한 예외는 잡지 않으므로 프로그램이 그대로 멈춘다. 이는 테스트 코드 자체의 결함을 숨기지 않기 위해서다.
운영 조립. 마지막 부분은 같은 DelayChecker 에 TimeProvider.System 과 ConsoleNotifier 를 넘긴다. 접수 시각을 현재보다 30시간 앞으로 잡았으므로 출력이 실행 시각과 관계없이 정해진다. 보간 문자열 안의 NotifyDelayed 호출이 먼저 실행되므로 알림 줄이 결과 줄보다 앞에 나온다.
실행 결과
$ dotnet run
통과 한도 안의 소포는 알리지 않는다
통과 정확히 한도 시각이면 아직 지연이 아니다
통과 한도를 넘기면 알리고 Delayed 로 바꾼다
통과 같은 소포를 두 번 알리지 않는다
통과 배송 완료 소포는 건너뛴다
통과 알림이 실패하면 상태를 바꾸지 않는다
통과 6, 실패 0
[알림] 이한솔: 소포 P-900 배송이 지연되고 있습니다.
운영 조립 결과: 1건 알림
실무에서 자주 틀리는 것
테스트 안에서 실제 시계로 기다린다
틀린 코드는 실제 시간이 흐르기를 기다리거나, 기대값을 실제 시계로 계산한다.
scene.Receive("P-100", "김하늘");
Thread.Sleep(TimeSpan.FromHours(24)); // 테스트가 하루 걸린다
고친 코드는 시계 객체를 옮긴다. 기대값에 쓸 시각도 Scene.Start 처럼 고정한 값에서 계산한다.
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(25));
인터페이스를 구현체 모양대로 크게 만든다
틀린 코드는 저장소가 할 수 있는 모든 일을 인터페이스에 넣는다.
interface IParcelStore
{
Parcel? Find(string id);
void Save(Parcel p);
void Delete(string id);
IReadOnlyList<Parcel> ListAll();
IReadOnlyList<Parcel> ListByStatus(ParcelStatus s);
IReadOnlyList<Parcel> ListByRecipient(string name);
int Count();
}
지연 판정기는 그중 셋만 쓴다. 테스트 더블은 나머지 넷도 구현해야 하고, 그 구현이 틀려도 지연 판정 테스트는 알아채지 못한다. 고친 코드는 완성 코드처럼 사용하는 쪽이 필요한 메서드만 남긴다. 다른 기능이 삭제를 필요로 하면 그 기능 전용의 작은 인터페이스를 따로 둔다.
구현 세부를 검증한다
틀린 코드는 내부에서 저장소를 몇 번 호출했는지 세는 식으로 검증한다.
Check.Equal(1, store.SaveCallCount); // 저장을 한 번만 호출했는가
내부를 "저장 두 번 + 조회 한 번"으로 리팩터링하면 동작은 같은데 테스트가 깨진다. 고친 코드는 결과를 본다. 알림 목록에 무엇이 나갔는지, 저장소에서 꺼낸 소포의 상태가 무엇인지를 확인한다. 호출 기록은 발송처럼 바깥으로 나가는 부수 효과에만 스파이로 쓴다.
Tests/ 폴더가 앱 프로젝트에 컴파일된다
앱 프로젝트 디렉터리 아래에 Tests/ 를 만들고 아무 설정도 하지 않으면 다음과 같은 오류가 난다.
error CS0246: 'Xunit' 형식 또는 네임스페이스 이름을 찾을 수 없습니다
앱 프로젝트의 기본 glob 이 Tests/*.cs 를 포함하기 때문이다. 고친 코드는 앱의 .csproj 에 <Compile Remove="Tests/**" /> 를 넣어 제외한다. 테스트 프로젝트는 자체 .csproj 로 따로 빌드하고 앱을 참조한다.
한눈에 보기
| 점검 | 나쁜 신호 | 고치는 방법 |
|---|---|---|
| 시간 | 본문에 DateTime.UtcNow | TimeProvider 를 생성자로 받는다 |
| 저장·발송 | 정적 클래스 호출 | 작은 인터페이스를 생성자로 받는다 |
| 검증 대상 | 내부 호출 횟수 | 결과 상태와 바깥으로 나간 내용 |
| 대기 | Thread.Sleep | 시계 객체의 Advance |
| 순서 | 딕셔너리 순회 순서에 의존 | 정렬해서 돌려준다 |
| 프로젝트 | 앱이 Tests/ 를 컴파일 | Compile Remove 와 별도 프로젝트 |
| 테스트 | 경과 시간 | 기대 결과 |
|---|---|---|
| 한도 안 | 23시간 | 0건 |
| 경계 | 24시간 | 0건 |
| 한도 초과 | 25시간 | 1건, 상태 Delayed |
| 재실행 | 25시간 후 두 번 호출 | 1건, 0건 |
| 발송 실패 | 25시간 | 0건, 상태 Received 유지 |
연습 문제
- 접수 후 25시간이 지난 소포
P-101,P-100두 건을 이 순서로 접수하고NotifyDelayed를 호출하는 테스트를Scene으로 작성하라. 알림이 ID 순서로 나가는지 확인한다. FakeClock.Advance에 음수를 넘기면 예외를 던지도록 고치고, 이를 확인하는 테스트를 쓰라.- 완성 코드의
InMemoryParcelStore,SpyNotifier,ThrowingNotifier는 각각 스텁·스파이·페이크 중 무엇인가. 이유와 함께 답하라. - 다음 클래스는 "오늘 접수분"을 셀 때 날짜가 바뀌는 시각에 따라 테스트 결과가 달라진다.
TimeProvider로 바꾸되, 테스트가 실행 환경의 시간대에 영향받지 않도록FakeClock도 고치라.public int CountToday(IEnumerable<Parcel> parcels) => parcels.Count(p => DateOnly.FromDateTime(p.ReceivedAt.LocalDateTime) == DateOnly.FromDateTime(DateTime.Now));
정답과 해설
1번. 저장소가 ID 순으로 돌려주므로 P-100 이 먼저 나가야 한다.
runner.Run("지연 알림은 ID 순서로 나간다", () =>
{
var scene = new Scene();
scene.Receive("P-101", "박민준");
scene.Receive("P-100", "김하늘");
scene.Clock.Advance(TimeSpan.FromHours(25));
int count = scene.Checker.NotifyDelayed(limit);
Check.Equal(2, count);
Check.True(scene.Notifier.Sent[0].Message.Contains("P-100"), "첫 알림은 P-100");
Check.True(scene.Notifier.Sent[1].Message.Contains("P-101"), "둘째 알림은 P-101");
});
접수 순서가 아니라 저장소의 정렬 규칙이 발송 순서를 정한다는 점을 확인하는 테스트다.
2번. 시계가 뒤로 가면 판정 결과의 의미가 흐려지므로 입구에서 막는다.
public void Advance(TimeSpan by)
{
if (by < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(by), "시계를 되돌릴 수 없다");
}
_now += by;
}
테스트는 try 안에서 Advance(TimeSpan.FromHours(-1)) 를 호출하고, ArgumentOutOfRangeException 을 잡았을 때만 통과로 처리하면 된다. 예외가 나지 않으면 Check.True(false, ...) 로 실패시킨다.
3번. InMemoryParcelStore 는 페이크다. 저장과 조회가 실제로 동작하는 단순 대체 구현이다. SpyNotifier 는 스파이다. 받은 호출을 기록해 나중에 검사할 수 있게 한다. ThrowingNotifier 는 스텁이다. 호출 내용을 기록하지 않고 정해진 행동(예외)만 하며, 실패 경로를 강제로 만드는 데 쓴다.
4번. 시각은 TimeProvider 로 읽고, 지역 날짜 계산은 GetLocalNow() 를 쓴다. 이 값은 LocalTimeZone 에 따르므로 테스트 시계에서 UTC로 고정한다.
public int CountToday(IEnumerable<Parcel> parcels, TimeProvider time)
{
TimeZoneInfo zone = time.LocalTimeZone;
DateOnly today = DateOnly.FromDateTime(time.GetLocalNow().DateTime);
return parcels.Count(p =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(p.ReceivedAt, zone).DateTime) == today);
}
// FakeClock 에 추가
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
기존 코드는 접수 시각의 지역 날짜와 DateTime.Now 를 비교하므로, 실행 환경의 시간대와 실행 시각이 모두 결과에 스며든다. 두 값을 같은 TimeProvider 에서 얻고 시간대를 고정하면 어느 머신에서 돌려도 같은 답이 나온다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.