유닛 테스트와 결합 테스트의 경계를 어디에 둘 것인가
· 업데이트: · Go Komura · 테스트, 유닛 테스트, 결합 테스트, 테스트 설계, Windows 개발, C# / .NET
수정 이력(4건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하면 됩니다.
- C#과 .NET을 전제로 한다는 점, 코드 예가 xUnit이라는 점을 서두에 명시했습니다. 유닛 테스트와 결합 테스트의 최소 예를 각각 새로 만들고, 테스트 더블 분류표(stub, mock, fake)를 추가해 「mock이 7개」라는 서술에 연결했습니다. E2E를 이 글에서 어떻게 다루는지도 명시했습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635226)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「유닛 테스트와 결합 테스트의 경계를 어디에 둘 것인가」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635226 https://comcomponent.com/ko/blog/unit-test-vs-integration-test-boundary-guide/
- DOI(최신 버전)
- 10.5281/zenodo.21635226
- DOI(이 버전)
- 10.5281/zenodo.21635227
테스트 설계 이야기에서 매번 은근히 어려운 것은, 어디까지를 유닛 테스트에 넣고 어디부터를 결합 테스트로 올릴지입니다.
여기서 위험한 것은,
- 빨리 돌리고 싶어서 무엇이든 유닛 테스트로 만든다
- 실물에 가깝다는 이유로 무엇이든 결합 테스트로 만든다
라는 양극단입니다.
전자는 mock투성이가 되어 운영에서 깨지는 지점을 놓치기 쉽고, 후자는 느리고 깨지기 쉬운 테스트 묶음이 되기 쉽습니다. 실무에서 봐야 할 축은 그보다 분명합니다.
- 확인하고 싶은 것은 우리 쪽 로직인가, 외부와의 연결인가
- in-memory fake로 바꿔도 의미가 약해지지 않는가
- DB / 파일 / HTTP / DI / 설정 / 프레임워크 / OS의 동작이 확인하려는 대상인가
- 많은 입력 패턴을 빠르게 돌리고 싶은가
이 네 가지가 보이면, 유닛 테스트와 결합 테스트의 경계는 꽤 긋기 쉬워집니다.
이 글은 C# / .NET의 자동 테스트 설계를 전제로 씁니다. 코드 예는 xUnit이지만, 판단 기준 자체는 프레임워크에 의존하지 않습니다. JUnit이든 pytest든, 그대로 바꿔 읽을 수 있는 형태로 두었습니다.
내용은 2026년 3월 시점에 참조할 수 있는 Microsoft Learn의 「Integration tests in ASP.NET Core」1, 같은 곳의 「Unit testing best practices for .NET」2, 그리고 Martin Fowler의 「The Practical Test Pyramid」3를 전제로 합니다. 이후에는 번호만으로 인용하지 않도록, 본문에도 출처 이름을 적습니다. 원전의 URL은 글 끝의 참고 자료에 모았습니다.
1. 먼저 결론
꽤 거칠게, 다만 실무에서 쓰기 쉬운 말로 하면 이렇습니다.
- 순수 로직은 유닛 테스트
- 연결·배선·변환·환경 차이는 결합 테스트
- 어느 쪽으로든 검증할 수 있다면, 먼저 유닛 테스트
- 결합 테스트는 넓고 무겁게 하기보다, 경계를 좁게 잡는다
한 줄로 말하면, 유닛 테스트는 「판단의 테스트」, 결합 테스트는 「연결의 테스트」입니다.
금액 계산, 상태 전이, 입력 검증, 승인 조건, 예외 분류처럼 외부 자원 없이 의미가 끝나는 것은 유닛 테스트로 두는 편이 빠르고, 깨지기 어렵고, 입력 패턴도 두껍게 돌릴 수 있습니다. 반면 SQL 실행, JSON / CSV 직렬화, 라우팅, 모델 바인딩, DI 등록, 파일 잠금, 권한, COM 등록, 32bit / 64bit, STA / MTA처럼 「연결되는 순간에 어긋나는 것」은 결합 테스트 쪽에 두는 편이 안전합니다.
Microsoft Learn의 Integration tests in ASP.NET Core에서도, 통합 테스트는 중요한 인프라 시나리오로 좁히고, 유닛 테스트로 충분하면 그쪽을 고르도록 정리되어 있습니다.
이 글의 지식 맵
이 글은 유닛 테스트와 통합 테스트의 경계를, 판단을 검증하는 테스트인지 연결을 검증하는 테스트인지라는 축으로 정리합니다. 금액 계산이나 상태 전이처럼 분기를 모두 훑는 검사는 유닛 테스트 쪽으로 모으고, 포맷·wiring·환경·시간이라는 네 가지 경계는 in-memory fake로는 의미가 떨어지므로 통합 테스트에 두어야 한다고 합니다. stub·mock·fake라는 테스트 더블의 차이를 정리한 다음, mock이 너무 늘어나는 징후는 wiring을 유닛 테스트로 밀어 넣고 있다는 신호이며, 코어 계층은 유닛 테스트로 두껍게, 경계 계층은 좁은 통합 테스트로, 전체 계층은 소수의 smoke/E2E로 구성하는 3계층 구성을 실무에서의 착지점으로 제시합니다.
flowchart LR
accTitle: 유닛 테스트와 통합 테스트 경계의 지식 맵
accDescr: 유닛 테스트가 판단을 검증하는 테스트이고 통합 테스트가 연결을 검증하는 테스트라는 점, 포맷·wiring·환경·시간이라는 네 가지 경계를 통합 테스트 쪽에 두어야 한다는 점, stub·mock·fake의 차이와 mock 과다가 wiring이 섞여 들어왔음을 보이는 징후라는 점, 코어 계층·경계 계층·전체 계층으로 이루어진 3계층 테스트 구성의 관계를 보여주는 그림
unit_test["단위 테스트"]
integration_test["통합 테스트"]
test_stub["스텁(Stub)"]
test_mock["목(Mock)"]
test_fake["페이크(Fake)"]
format_boundary["포맷 경계"]
wiring_boundary["배선 경계"]
environment_boundary["환경 경계"]
time_boundary["시간의 경계"]
branching_coverage["분기 전수 커버리지"]
bitness_match_requirement["비트수 일치 요건"]
com_apartment_model["COM 아파트먼트 모델(STA/MTA)"]
mock_overuse_smell["mock이 너무 늘어나는 징후"]
three_layer_test_structure["3계층 테스트 구성"]
e2e_test["E2E 테스트"]
unit_test -.->|"이용한다"| test_stub
unit_test -.->|"이용한다"| test_mock
unit_test -.->|"이용한다"| test_fake
integration_test -->|"권장되는 대응"| format_boundary
integration_test -->|"권장되는 대응"| wiring_boundary
integration_test -->|"권장되는 대응"| environment_boundary
integration_test -->|"권장되는 대응"| time_boundary
unit_test -->|"권장되는 대응"| branching_coverage
environment_boundary -.->|"전제로 한다"| bitness_match_requirement
environment_boundary -.->|"전제로 한다"| com_apartment_model
test_mock -.->|"원인이 될 수 있다"| mock_overuse_smell
unit_test -->|"사용은 비권장"| wiring_boundary
three_layer_test_structure -->|"전제로 한다"| unit_test
three_layer_test_structure -->|"전제로 한다"| integration_test
three_layer_test_structure -->|"전제로 한다"| e2e_test
integration_test -->|"사용은 비권장"| branching_coverage
test_fake -->|"사용은 비권장"| environment_boundary
test_fake -->|"사용은 비권장"| format_boundary
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 18건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 이 글에서 말하는 유닛 테스트와 결합 테스트
여기서는 용어를 이렇게 나눕니다.
| 레벨 | 무엇을 확인하는가 | 전형적인 구성 |
|---|---|---|
| 유닛 테스트 | 분리된 1개 책임의 올바름 | fake / mock / stub을 써서 외부 자원을 끊는다 |
| 결합 테스트 | 여러 컴포넌트의 연결과, 인프라나 프레임워크를 포함한 동작 | 실제 DB, 실제 파일, 실제 serializer, 실제 host, 실제 pipeline 등 |
| E2E / 기능 테스트 | 앱 전체의 사용자 흐름 | 배포된 앱, 여러 서비스, 실제 브라우저나 실제 프로세스 |
.NET의 유닛 테스트 정리에서는, 좋은 유닛 테스트는 fast / isolated / repeatable이며 파일 시스템이나 DB 같은 외부 요인에 의존하지 않는 것으로 설명됩니다. 자세한 내용은 Unit testing best practices for .NET이 이해하기 쉽습니다.
또한 결합 테스트는 「다른 프로세스나 다른 서버를 반드시 쓰는 무거운 테스트」만을 가리키지 않습니다. 같은 프로세스 안이라도, 여러 실제 컴포넌트를 잇고 프레임워크나 인프라의 실제 동작을 확인한다면, 그것은 결합 테스트에 가깝습니다.
예를 들어 ASP.NET Core의 controller action을 유닛 테스트할 때, 대상은 action 본체의 판단으로 좁히고 routing, model binding, filters 같은 프레임워크 쪽 상호작용은 결합 테스트에서 다룬다는 구분이 공식에도 나와 있습니다. 자세한 내용은 Unit test controller logic in ASP.NET Core를 보면 정리하기 쉽습니다.
2.1. fake / mock / stub의 구별
위 표에서 fake / mock / stub을 나란히 썼지만, 이 셋은 같은 것이 아닙니다. 테스트 더블(테스트용 대역)의 분류로, 이 글에서는 다음 의미로 씁니다. Martin Fowler의 「Mocks Aren’t Stubs」 정리에 맞춘 구분입니다.4
| 호칭 | 무엇을 하는가 | 전형적인 쓰임 |
|---|---|---|
| stub | 정해 둔 값만 돌려주는 대역. 호출 방식은 검증하지 않는다 | 「재고는 항상 3개」처럼 입력 조건을 고정하고 싶을 때 |
| mock | 호출 방식 자체를 검증하는 대역. 호출 횟수나 인자를 assert한다 | 「저장이 한 번만 호출되는지」를 확인하고 싶을 때 |
| fake | 실물과 같은 동작을 가벼운 구현으로 바꿔 둔 것 | in-memory 리포지토리, 임시 디렉터리 위의 파일 저장소 등 |
거칠게 말하면, stub은 입력의 대역, mock은 호출의 검증, fake는 간이 구현입니다. 이 구분이 효력을 내는 곳은 4장입니다. 「mock이 7개 필요하다」는 징후는, 정확히는 호출 검증 대상이 7개 있다는 뜻이고, 그 테스트가 하나의 판단이 아니라 여러 부품을 잇는 방식을 확인하려 한다는 신호입니다.
2.2. 이 글에서 E2E를 어떻게 다루는가
이 글의 주제는 어디까지나 유닛 테스트와 결합 테스트의 경계입니다. E2E / 기능 테스트는 대비를 위해 위 표에 넣었지만, 본문에서는 깊게 다루지 않습니다.
위치만 확인해 두면, 테스트 피라미드 사고에서는 아래 층일수록 개수가 많고 빠르며, 위 층일수록 개수가 적고 느린 삼각형이 됩니다. Martin Fowler의 「The Practical Test Pyramid」가 이 정리의 출발점입니다.3 즉 유닛 테스트를 바탕으로 두고, 결합 테스트를 경계마다 두며, E2E는 주요 흐름만으로 좁히는 비율입니다. 구체적인 배치는 7장의 3층 구성에 모았습니다.
3. 한 장으로 보는 판단표
우선 실무에서 가장 쓰기 쉬운 표를 둡니다.
| 확인하고 싶은 것 | 주력으로 둘 테스트 | 보충 |
|---|---|---|
| 금액 계산, 할인, 상태 전이, 입력 검증 | 유닛 테스트 | 입력 패턴을 두껍게 돌리고 싶다 |
| 예외 분류, 오류 메시지 선택, 재시도할지에 대한 판단 | 유닛 테스트 | 실제 I/O 없이 의미가 끝난다 |
| Repository의 SQL / ORM 변환, transaction | 결합 테스트 | 실제 DB나 실제 provider의 동작이 확인 대상 |
| JSON / XML / CSV의 serialize / deserialize | 결합 테스트 | wire format의 어긋남은 fake로는 찾기 어렵다 |
| 라우팅, 모델 바인딩, 필터, middleware | 결합 테스트 | 프레임워크와의 연결 확인 |
| WPF / WinForms의 ViewModel이나 Presenter의 상태 전이 | 유닛 테스트 | UI를 띄우지 않아도 의미가 있다 |
| 실제 Binding, Dispatcher, control lifecycle, message loop | 결합 테스트 or UI 테스트 | 프레임워크와 스레드의 동작이 주제 |
| 파일 경로, 권한, 잠금, 공유 폴더, 줄바꿈, 문자 인코딩 | 결합 테스트 | OS와 파일 시스템의 실제 동작이 필요하다 |
| COM 등록, 32bit / 64bit, STA / MTA, DLL 로드 | 결합 테스트 | 환경 차이와 프로세스 경계가 주제 |
| 앱 전체의 시작, 주요 유스케이스의 통과 확인 | E2E / smoke | 개수는 적어도 된다 |
보는 요령은, 어느 테스트가 「운영에서 깨지는 이유」에 가장 가까운가입니다. 코드가 놓인 곳이 아니라, 줄이고 싶은 불확실성으로 정하는 편이 흔들리지 않습니다.
4. 유닛 테스트에 두어야 할 것
유닛 테스트에 맞는 것은, 외부를 걷어내도 의미가 남는 책임입니다.
예를 들면 이런 것들입니다.
- 업무 규칙
- 분기
- 상태 전이
- 입력 검증
- 오류 분류
- 재시도 방침의 결정
- ViewModel / Presenter의 상태 변화
- 변환 로직 그 자체
특히, 조합이 많을수록 유닛 테스트로 두는 가치가 높습니다.
예를 들어,
- 쿠폰 있음 / 없음
- 재고 있음 / 없음
- 첫 주문 / 재주문
- 관리자 / 일반 사용자
- 정상값 / 경계값 / 잘못된 값
처럼 분기 조건이 늘수록, 결합 테스트로 전부 돌리기는 무거워집니다. 여기는 유닛 테스트로 잘게 나누는 편이 합리적입니다.
또한 유닛 테스트에서는 외부 요인을 제어 가능하게 두는 것이 중요합니다.
- 현재 시각은 주입한다
- GUID나 난수는 바꿀 수 있게 한다
- sleep으로 기다리지 않는다
- 실제 DB나 실제 파일에 손대지 않는다
- 실제 네트워크로 나가지 않는다
이 정도가 지켜지면, 테스트는 꽤 안정됩니다.
4.1. 유닛 테스트에서 mock이 너무 많아질 때
유닛 테스트를 쓰려 했더니,
- mock이 7개 필요하다
- setup이 길다
- arrange가 본체보다 길다
- 무엇을 확인하고 싶은지 보이지 않는다
가 된다면, 대개 다음 둘 중 하나입니다.
- 그 클래스의 책임이 과다하다
- 원래 결합 테스트로 확인해야 할 배선을 유닛 테스트에 억지로 넣고 있다
mock은 외부를 끊기 위한 도구이지, 실물과의 연결이 올바른지를 증명하는 도구가 아닙니다. 여기를 혼동하면, 「전부 green인데 운영에서 깨진다」가 나오기 쉽습니다.
4.2. 유닛 테스트의 최소 예
말만으로는 추상적이니, 경계의 양쪽을 코드로 하나씩 둡니다. 먼저 유닛 테스트 쪽입니다. ViewModel의 상태 전이는 UI를 띄우지 않아도 의미가 끝나므로, 여기는 유닛 테스트의 영역입니다.
// .NET 8 / xUnit
// 대상: 외부 자원에 전혀 손대지 않는 ViewModel
public sealed class OrderViewModel
{
public decimal Subtotal { get; set; }
public bool IsMember { get; set; }
public bool CanCheckout => Subtotal > 0m;
public decimal Total => IsMember ? Subtotal * 0.9m : Subtotal;
}
public class OrderViewModelTests
{
[Fact]
public void 会員なら1割引きになる()
{
var viewModel = new OrderViewModel { Subtotal = 1000m, IsMember = true };
Assert.Equal(900m, viewModel.Total);
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
public void 小計が0なら確定できない(int subtotal, bool expected)
{
var viewModel = new OrderViewModel { Subtotal = subtotal };
Assert.Equal(expected, viewModel.CanCheckout);
}
}
여기에는 DB도 파일도 HTTP도 나오지 않습니다. 그래서 빠르고, 병렬로 돌릴 수 있으며, [Theory]로 입력 패턴을 얼마든지 늘릴 수 있습니다.
분기 전수를 여기로 모은다는 말은, 구체적으로는 이 형태를 가리킵니다.
5. 결합 테스트로 올릴 네 가지 경계
결합 테스트로 올려야 할 곳은, 대체로 포맷, 배선, 환경, 시간 네 가지로 정리할 수 있습니다.
5.1. 포맷의 경계
여기서 말하는 포맷에는 이런 것이 들어갑니다.
- JSON / XML / CSV
- DB의 schema와 mapping
- nullable / precision / timezone
- enum이나 날짜의 직렬화
- 문자 인코딩이나 BOM
- 줄바꿈
Martin Fowler도 serialize / deserialize가 들어가는 경계는 결합 테스트 후보로 꼽습니다. 자세한 내용은 The Practical Test Pyramid가 참고가 됩니다.
예를 들어,
- DTO를 JSON으로 만들었더니 필드 이름이 달랐다
- CSV의 따옴표나 줄바꿈이 깨졌다
decimal이 반올림되었다- DB에서
DateTimeOffset처리가 어긋났다 null과 빈 문자열 처리가 예상과 달랐다
같은 장애는 유닛 테스트만으로는 빠지기 쉽습니다.
5.2. 배선의 경계
배선 경계에 들어가는 것은, 예를 들어 이런 부분입니다.
- DI 등록
- 설정의 bind
- 라우팅
- 모델 바인딩
- 필터
- middleware
- host의 시작
- 이벤트 배선
- WPF의 Binding이나 command 연결
여기는 「내 함수가 올바른가」가 아니라, 여러 실제 부품이 올바르게 이어져 있는가가 확인 대상입니다.
ASP.NET Core에서는 controller action의 유닛 테스트는 action의 판단으로 좁히고, routing이나 model binding, filters는 결합 테스트 쪽에서 본다는 정리가 공식에 있습니다.
웹이 아니어도 사고는 같고, 데스크톱 앱에서도 ViewModel의 상태 전이는 유닛 테스트, 실제 XAML Binding이나 Dispatcher를 포함한 동작은 결합 테스트에 가깝습니다.
5.3. 환경의 경계
Windows 개발에서는 여기가 꽤 중요합니다.
- 파일 권한
- 공유 폴더
- 파일 잠금
- 임시 파일에서의 rename
- 관리자 권한
- 서비스 시작 권한
- COM 등록
- 32bit / 64bit
- STA / MTA
- DLL의 로드 위치
이 부근은 OS나 실행 환경의 조건 그 자체가 주역입니다. in-memory fake로는 의미가 꽤 약해지므로, 결합 테스트로 잡는 편이 안전합니다.
특히 기존 Windows 소프트웨어나 COM / ActiveX가 포함된 구성에서는, 로직보다 먼저 등록, bitness, 스레드 모델, 권한에서 넘어지는 일이 흔합니다. 이런 실패는 유닛 테스트가 아니라, 환경을 포함한 결합 테스트가 건지는 영역입니다.
5.4. 시간의 경계
또 하나 놓치기 쉬운 것이, 시간과 동시성입니다.
- timeout
- cancellation
- retry의 실제 동작
- timer 구동
- 백그라운드 처리의 정지
- race condition
- shutdown 시의 종료 순서
여기서 중요한 것은 판단과 실제 동작을 나누는 것입니다.
예를 들어,
- 몇 회까지 retry할지
- 어떤 예외를 retry 대상으로 할지
는 유닛 테스트로 충분합니다. 반면,
- 실제로 timeout이 먹히는지
- cancellation이 전파되는지
- timer와 비동기 처리가 겹쳤을 때 깨지지 않는지
- 종료 시 핸들이나 작업이 깨끗이 닫히는지
는 결합 테스트에 가깝습니다.
5.5. 결합 테스트의 최소 예
4.2와 같은 소재로, 경계의 다른 쪽을 써 봅니다. 여기서 확인하고 싶은 것은 「저장한 금액이 DB를 왕복해도 깨지지 않는가」입니다. 실제 SQLite 파일을 통과시킵니다.
// .NET 8 / xUnit / Microsoft.Data.Sqlite
using System.Globalization;
using Microsoft.Data.Sqlite;
public sealed class OrderRepository(SqliteConnection connection)
{
public void Save(int id, decimal total)
{
using var command = connection.CreateCommand();
command.CommandText = "INSERT INTO orders (id, total) VALUES ($id, $total);";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$total", total.ToString(CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
public decimal FindTotal(int id)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT total FROM orders WHERE id = $id;";
command.Parameters.AddWithValue("$id", id);
var stored = (string)command.ExecuteScalar()!;
return decimal.Parse(stored, CultureInfo.InvariantCulture);
}
}
public sealed class OrderRepositoryTests : IDisposable
{
private readonly string _databasePath =
Path.Combine(Path.GetTempPath(), $"orders-{Guid.NewGuid():N}.db");
private readonly SqliteConnection _connection;
public OrderRepositoryTests()
{
// Pooling=False로 두지 않으면, 뒷정리에서 db 파일을 지우지 못하는 경우가 있습니다
_connection = new SqliteConnection($"Data Source={_databasePath};Pooling=False");
_connection.Open();
using var create = _connection.CreateCommand();
create.CommandText = "CREATE TABLE orders (id INTEGER PRIMARY KEY, total TEXT NOT NULL);";
create.ExecuteNonQuery();
}
[Fact]
public void 保存した金額が丸められずに読み戻せる()
{
var repository = new OrderRepository(_connection);
repository.Save(id: 1, total: 1234.56m);
Assert.Equal(1234.56m, repository.FindTotal(1));
}
public void Dispose()
{
_connection.Dispose();
File.Delete(_databasePath);
}
}
이 테스트가 확인하는 것은 OrderRepository의 분기가 아닙니다. SQL이 통과하는지, 열 타입과 decimal의 대응이 맞는지, 왕복해도 값이 유지되는지라는 연결 부분입니다. SQLite에는 decimal 타입이 없으므로, 「어느 타입으로 저장해야 왕복해도 깨지지 않는가」는 구현의 문제가 아니라 연결의 설계 문제입니다. 여기는 in-memory fake로는 보이지 않습니다.
이 예에서는 테스트 클래스마다 임시 DB 파일을 하나 만들고, Dispose에서 지웁니다. 결합 테스트는 상태를 가지므로, 어디서 만들고 어디서 버릴지를 매번 분명히 하는 것이 유닛 테스트보다 중요해집니다.
6. 흔한 판단 실수
6.1. Repository를 mock으로 통과시키고 만족한다
Repository 주변을 전부 mock으로 통과시켜도,
- SQL이 올바른지
- transaction이 먹히는지
- schema와 일치하는지
- mapping이 어긋나지 않는지
- 문자 인코딩이나 precision이 깨지지 않는지
는 알 수 없습니다.
Repository는 로직의 테스트 대상이라기보다 경계의 연결점인 경우가 많습니다. 그때는 유닛 테스트보다 결합 테스트의 비중을 올리는 편이 실태에 맞습니다.
6.2. Controller / Endpoint의 유닛 테스트에서 프레임워크까지 보려 한다
controller action의 유닛 테스트에서 보고 싶은 것은,
- 조건 분기
- 반환값의 선택
- 의존 서비스를 어느 쪽으로 호출할지
정도입니다.
반면,
- route가 맞는지
- model binding이 통과하는지
- filter가 먹히는지
- middleware를 통과한 결과가 어떻게 보이는지
는 결합 테스트 쪽입니다. 여기를 섞으면, 무엇이 깨졌는지 알기 어려워집니다.
6.3. 결합 테스트에서 입력 패턴을 전수한다
결합 테스트는 실물에 가까운 만큼, 아무래도 느려집니다. 그래서 분기 전수는 유닛 테스트, 경계의 대표 케이스는 결합 테스트로 나누는 편이 이득입니다.
Microsoft Learn의 통합 테스트 해설에서도, DB나 파일 시스템에 대해서는 모든 패턴을 결합 테스트로 돌리지 말고 read / write / update / delete 같은 대표적인 시나리오로 좁히는 방향이 권장됩니다.
6.4. CI에서 외부 서비스의 운영 환경을 그대로 친다
이것은 피하는 편이 안전합니다.
결합 테스트는 「실물다움」이 중요하지만, 그렇다고 매번 운영 SaaS나 운영 API를 칠 필요는 없습니다. Fowler도 외부 서비스는 로컬에 세우거나, fake를 두거나, 전용 test instance를 쓰는 방향을 권합니다.
실무에서는,
- 로컬 DB
- 임시 디렉터리
- test host
- 전용 test environment
- 계약을 고정한 fake service
의 조합이 다루기 쉽습니다.
7. 실무에서 권하는 구성
비율에 절대적인 정답은 없습니다. 다만 꽤 범용적으로 쓸 수 있는 것은, 다음 3층입니다.
| 층 | 주력 | 무엇을 두는가 |
|---|---|---|
| 코어 층 | 유닛 테스트를 두껍게 | 업무 규칙, 상태 전이, 입력 검증, 오류 분류 |
| 경계 층 | 좁은 결합 테스트를 둔다 | DB, 파일, HTTP, serializer, DI, 설정, COM, 권한 |
| 전체 층 | 소수의 smoke / E2E | 시작 확인, 주요 흐름, 중대 장애의 재발 방지 |
감각적으로는, 개수로 두꺼워지는 것은 유닛 테스트, 경계의 농도로 두꺼워지는 것이 결합 테스트입니다.
권하는 진행 방식은 이렇습니다.
- 먼저 앱의 경계를 나열한다
- 로직을 외부에서 끊을 수 있는 형태로 모은다
- 경계마다 「최소 1개의 happy path」와 「대표적인 failure path」를 둔다
- 전체 통과는 개수를 줄인다
- 버그가 나오면, 그 버그를 최소 비용으로 재현할 수 있는 층에 테스트를 추가한다
마지막 5가 중요합니다.
- 규칙의 오류라면 유닛 테스트를 추가한다
- SQL / binding / 설정 / 권한 / 등록의 오류라면 결합 테스트를 추가한다
- 시작이나 배포를 포함한 장애라면 smoke나 E2E를 추가한다
이렇게 늘리면, 테스트의 책임이 흔들리기 어렵습니다.
8. 헤맬 때 마지막으로 보는 5가지 질문
마지막으로, 헤맬 때의 점검용으로 다섯 가지 질문에 모읍니다.
- in-memory fake로 바꿔도, 확인하고 싶은 의미는 남는가
- 남는다면 유닛 테스트에 가깝습니다.
- 깨졌을 때 의심하는 것은 로직이 아니라 연결이나 설정이 아닌가
- 그렇다면 결합 테스트에 가깝습니다.
- DB / 파일 / serializer / DI / route / model binding / OS / 권한 / bitness / thread가 주제가 아닌가
- 그렇다면 결합 테스트에 가깝습니다.
- 많은 입력 패턴을 빠르게 돌리고 싶은가
- 그렇다면 유닛 테스트에 가깝습니다.
- 그 테스트가 실패했을 때, 무엇을 고치면 되는지가 바로 보이는가
- 보이지 않는다면 테스트 층이 섞여 있습니다.
이 다섯 가지로 정리하면, 「왠지 실물에 가까우니까 결합 테스트」「왠지 빠르니까 유닛 테스트」라는 거친 정하기를 피하기 쉬워집니다.
9. 정리
유닛 테스트와 결합 테스트의 경계는, 코드가 놓인 곳이 아니라 어떤 불확실성을 줄이고 싶은가로 정하는 것이 가장 실무적입니다.
요점은 이 다섯에 모입니다.
- 유닛 테스트는 판단의 테스트
- 결합 테스트는 연결의 테스트
- 분기 전수는 유닛 테스트
- 포맷, 배선, 환경, 시간은 결합 테스트
- 전체 통과 확인은 소수의 smoke / E2E로 잡는다
가장 피하고 싶은 것은,
- mock으로 실물과의 연결까지 증명했다고 착각한다
- 결합 테스트에서 모든 분기를 돌리려 한다
- 유닛 테스트와 결합 테스트의 책임을 섞는다
이 셋입니다.
헤매면, 그 장애는 「판단」이 깨지는 것인지, 「연결」이 깨지는 것인지를 먼저 보십시오. 이 한 물음으로, 꽤 많은 경우는 정리됩니다.
10. 관련 글
- Windows 앱 개발에서 최소한의 보안을 지키기 위한 체크리스트
- Windows 앱을 정말로 단일 바이너리로 만들 수 있는 범위는 어디까지인가
- Windows에서 관리자 권한이 필요해지는 때는 언제인가
- Reg-Free COM이란 무엇인가
11. 참고 자료
-
Microsoft Learn, Integration tests in ASP.NET Core ↩
-
Microsoft Learn, Unit testing best practices for .NET ↩
-
Martin Fowler, The Practical Test Pyramid ↩ ↩2
-
Martin Fowler, Mocks Aren’t Stubs ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
자작 logger의 최소 요건과 결합 테스트 체크리스트
자작 앱의 진단 로그를 신뢰할 수 있게 만들기 위해, UTF-8 JSON Lines, 필수 항목, flush·로테이션·이상 시 동작과, 실제 파일·실제 스레드에서의 결합 테스트 관점을 정리합니다.
Windows Sandbox로 앱 검증을 빠르게 하는 방법
Windows Sandbox로 관리자 권한 문제의 원인 분리, 클린 환경에서의 재현, 권한 부족·리소스 부족의 재현을 효율화하는 방법을 .wsb와 CLI의 용도 구분까지 포함해 정리합니다.
예기치 않은 예외가 났을 때 종료할지 계속할지 판단표
예상하지 못한 예외가 났을 때 앱을 종료할지 계속할지를, 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
Windows 앱에서 “관리자 권한이 필요한 처리만” 분리하는 구체적인 작성법
Windows 앱에서 UI는 asInvoker로 두고 관리자 권한이 필요한 처리만 helper EXE로 분리하는 설계를 UAC, runas, named pipe, 입력 검증까지 포함해 구체적으로 정리합니다.
Windows 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피하기
Windows 앱에서 접속 정보나 API 토큰을 설정 파일에 평문으로 저장하지 않기 위해, DPAPI / ProtectedData의 사고방식, CurrentUser와 LocalMachine의 차이, 구현 시 주의점을 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
유닛 테스트와 결합 테스트의 경계를 어디서 자를지는, 구현 전 설계 리뷰나 테스트 전략 상담으로 정리하기 쉽기 때문입니다.
Windows 앱 개발
Windows 앱에서는 파일, 권한, COM, 32bit / 64bit 같은 경계가 테스트 층에도 바로 이어지므로, 구현 방침을 정리하는 일과 잘 맞기 때문입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 유닛 테스트와 결합 테스트는 어떻게 나눠 쓰면 되나요?
- 한 줄로 말하면, 유닛 테스트는 「판단의 테스트」, 결합 테스트는 「연결의 테스트」입니다. 금액 계산, 상태 전이, 입력 검증, 예외 분류처럼 외부 자원 없이 의미가 끝나는 것은 유닛 테스트로 두고, SQL 실행, JSON/CSV 직렬화, 라우팅, DI 등록, 파일 잠금, 권한, COM 등록, 32bit/64bit처럼 「연결되는 순간에 어긋나는 것」은 결합 테스트 쪽에 둡니다. 어느 쪽으로든 검증할 수 있다면, 먼저 유닛 테스트입니다.
- 어떤 것을 결합 테스트로 올려야 하나요?
- 대체로 포맷, 배선, 환경, 시간 네 가지 경계로 정리할 수 있습니다. 포맷은 JSON/CSV나 DB mapping, 문자 인코딩 등이고, 배선은 DI 등록, 라우팅, 모델 바인딩처럼 실제 부품을 잇는 일이며, 환경은 파일 권한, COM 등록, 32bit/64bit, STA/MTA 같은 OS의 실제 동작이고, 시간은 timeout, cancellation, race condition 등입니다. 이런 것들은 in-memory fake로는 확인하고 싶은 의미가 약해지므로, 결합 테스트로 잡는 편이 안전합니다.
- 유닛 테스트에서 mock이 너무 많아지는 것은 무엇이 문제인가요?
- mock이 7개 필요하고, setup이 길며, 무엇을 확인하고 싶은지 보이지 않는 상태라면, 그 클래스가 책임이 과다하거나, 원래 결합 테스트로 확인해야 할 배선을 유닛 테스트에 억지로 넣고 있는 경우 중 하나입니다. mock은 외부를 끊기 위한 도구이지, 실물과의 연결이 올바른지를 증명하는 도구가 아닙니다. 여기를 혼동하면 「전부 green인데 운영에서 깨진다」가 나오기 쉽습니다.
- 테스트는 어떤 구성·비율로 가져가는 것이 좋은가요?
- 범용적으로 쓸 수 있는 것은 3층 구성입니다. 코어 층은 업무 규칙이나 상태 전이를 유닛 테스트로 두껍게 두고, 경계 층은 DB·파일·serializer·DI 등에 좁은 결합 테스트를 두며, 전체 층은 시작 확인이나 주요 흐름을 소수 smoke/E2E로 잡습니다. 분기 전수는 유닛 테스트로 돌리고, 결합 테스트는 경계마다 최소 1개의 happy path와 대표적인 failure path로 좁힙니다. 버그가 나오면, 그 버그를 최소 비용으로 재현할 수 있는 층에 테스트를 추가합니다.