유닛 테스트와 결합 테스트의 경계를 어디에 둘 것인가

· 업데이트: · · 테스트, 유닛 테스트, 결합 테스트, 테스트 설계, 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. 먼저 결론

꽤 거칠게, 다만 실무에서 쓰기 쉬운 말로 하면 이렇습니다.

  1. 순수 로직은 유닛 테스트
  2. 연결·배선·변환·환경 차이는 결합 테스트
  3. 어느 쪽으로든 검증할 수 있다면, 먼저 유닛 테스트
  4. 결합 테스트는 넓고 무겁게 하기보다, 경계를 좁게 잡는다

한 줄로 말하면, 유닛 테스트는 「판단의 테스트」, 결합 테스트는 「연결의 테스트」입니다.

금액 계산, 상태 전이, 입력 검증, 승인 조건, 예외 분류처럼 외부 자원 없이 의미가 끝나는 것은 유닛 테스트로 두는 편이 빠르고, 깨지기 어렵고, 입력 패턴도 두껍게 돌릴 수 있습니다. 반면 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계층 구성을 실무에서의 착지점으로 제시합니다.

유닛 테스트와 통합 테스트 경계의 지식 맵유닛 테스트가 판단을 검증하는 테스트이고 통합 테스트가 연결을 검증하는 테스트라는 점, 포맷·wiring·환경·시간이라는 네 가지 경계를 통합 테스트 쪽에 두어야 한다는 점, stub·mock·fake의 차이와 mock 과다가 wiring이 섞여 들어왔음을 보이는 징후라는 점, 코어 계층·경계 계층·전체 계층으로 이루어진 3계층 테스트 구성의 관계를 보여주는 그림이용한다이용한다이용한다권장되는 대응권장되는 대응권장되는 대응권장되는 대응권장되는 대응전제로 한다전제로 한다원인이 될 수 있다사용은 비권장전제로 한다전제로 한다전제로 한다사용은 비권장사용은 비권장사용은 비권장단위 테스트통합 테스트스텁(Stub)목(Mock)페이크(Fake)포맷 경계배선 경계환경 경계시간의 경계분기 전수 커버리지비트수 일치 요건COM 아파트먼트 모델(STA/MTA)mock이 너무 늘어나는 징후3계층 테스트 구성E2E 테스트

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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가 본체보다 길다
  • 무엇을 확인하고 싶은지 보이지 않는다

가 된다면, 대개 다음 둘 중 하나입니다.

  1. 그 클래스의 책임이 과다하다
  2. 원래 결합 테스트로 확인해야 할 배선을 유닛 테스트에 억지로 넣고 있다

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. 먼저 앱의 경계를 나열한다
  2. 로직을 외부에서 끊을 수 있는 형태로 모은다
  3. 경계마다 「최소 1개의 happy path」와 「대표적인 failure path」를 둔다
  4. 전체 통과는 개수를 줄인다
  5. 버그가 나오면, 그 버그를 최소 비용으로 재현할 수 있는 층에 테스트를 추가한다

마지막 5가 중요합니다.

  • 규칙의 오류라면 유닛 테스트를 추가한다
  • SQL / binding / 설정 / 권한 / 등록의 오류라면 결합 테스트를 추가한다
  • 시작이나 배포를 포함한 장애라면 smoke나 E2E를 추가한다

이렇게 늘리면, 테스트의 책임이 흔들리기 어렵습니다.

8. 헤맬 때 마지막으로 보는 5가지 질문

마지막으로, 헤맬 때의 점검용으로 다섯 가지 질문에 모읍니다.

  1. in-memory fake로 바꿔도, 확인하고 싶은 의미는 남는가
    • 남는다면 유닛 테스트에 가깝습니다.
  2. 깨졌을 때 의심하는 것은 로직이 아니라 연결이나 설정이 아닌가
    • 그렇다면 결합 테스트에 가깝습니다.
  3. DB / 파일 / serializer / DI / route / model binding / OS / 권한 / bitness / thread가 주제가 아닌가
    • 그렇다면 결합 테스트에 가깝습니다.
  4. 많은 입력 패턴을 빠르게 돌리고 싶은가
    • 그렇다면 유닛 테스트에 가깝습니다.
  5. 그 테스트가 실패했을 때, 무엇을 고치면 되는지가 바로 보이는가
    • 보이지 않는다면 테스트 층이 섞여 있습니다.

이 다섯 가지로 정리하면, 「왠지 실물에 가까우니까 결합 테스트」「왠지 빠르니까 유닛 테스트」라는 거친 정하기를 피하기 쉬워집니다.

9. 정리

유닛 테스트와 결합 테스트의 경계는, 코드가 놓인 곳이 아니라 어떤 불확실성을 줄이고 싶은가로 정하는 것이 가장 실무적입니다.

요점은 이 다섯에 모입니다.

  • 유닛 테스트는 판단의 테스트
  • 결합 테스트는 연결의 테스트
  • 분기 전수는 유닛 테스트
  • 포맷, 배선, 환경, 시간은 결합 테스트
  • 전체 통과 확인은 소수의 smoke / E2E로 잡는다

가장 피하고 싶은 것은,

  • mock으로 실물과의 연결까지 증명했다고 착각한다
  • 결합 테스트에서 모든 분기를 돌리려 한다
  • 유닛 테스트와 결합 테스트의 책임을 섞는다

이 셋입니다.

헤매면, 그 장애는 「판단」이 깨지는 것인지, 「연결」이 깨지는 것인지를 먼저 보십시오. 이 한 물음으로, 꽤 많은 경우는 정리됩니다.

10. 관련 글

11. 참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

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로 좁힙니다. 버그가 나오면, 그 버그를 최소 비용으로 재현할 수 있는 층에 테스트를 추가합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기