Skip to main content

Command Palette

Search for a command to run...

단위 테스트 코드 작성 방법

예약 도메인 예제로 살펴보는 단위 테스트 코드 작성 방식

Updated
4 min read
💡
이 글은 다음 블로그를 참고하여 작성된 글입니다. https://mangkyu.tistory.com/143 https://tech.kakaopay.com/post/given-test-code-2/

단위 테스트란?

단위 테스트(Unit Test)는
하나의 모듈을 기준으로 독립적으로 수행되는 가장 작은 단위의 테스트이다.

여기서 말하는 모듈이란,
애플리케이션에서 동작하는 하나의 기능 또는 하나의 메서드를 의미한다.


단위 테스트의 어려움

일반적인 애플리케이션에서는
하나의 기능을 처리하기 위해 여러 객체와 메시지를 주고받는다.

하지만 단위 테스트는 특정 모듈을 독립적으로 검증하는 것이 목적이기 때문에,
다른 객체와의 협력이 포함되면 테스트가 어려워진다.

이 문제를 해결하기 위해,
단위 테스트에서는 실제 객체 대신 가짜 객체(Mock Object)를 주입하고
“어떤 상황에서 어떤 결과를 반환할지”를 미리 정의한다.

이렇게 미리 정해진 응답을 준비하는 과정을 Stub이라고 한다.


좋은 단위 테스트의 특징

좋은 단위 테스트는 다음 원칙을 따른다.

  • 하나의 테스트 함수는 하나의 개념만 검증한다

  • 테스트 함수 내에서 assert는 최소화한다

또한, 좋은 테스트 코드는 흔히 FIRST 원칙을 만족해야 한다.

  • Fast
    테스트는 빠르게 실행되어 자주 돌릴 수 있어야 한다.

  • Independent
    각 테스트는 서로 의존하지 않고 독립적으로 실행되어야 한다.

  • Repeatable
    어떤 환경에서도 동일한 결과를 보장해야 한다.

  • Self-Validating
    성공 또는 실패가 명확해야 하며, 추가 해석이 필요 없어야 한다.

  • Timely
    실제 코드를 작성하기 직전 또는 동시에 작성되어야 한다.


Given / When / Then 패턴

단위 테스트에서는
테스트의 의도를 코드만 보고도 이해할 수 있도록 만드는 것이 중요하다.

이를 위해 가장 널리 사용되는 구조가
Given / When / Then 패턴이다.

  • Given: 테스트를 위한 사전 조건을 준비한다.

  • When: 테스트 대상의 동작을 실행한다.

  • Then: 실행 결과를 검증한다.

이 패턴의 목적은

“이 테스트가 무엇을 검증하는지”
를 코드 구조만으로 드러내는 데 있다.


외부 의존성이 없는 경우: 순수 객체 기반 테스트

로또 번호 생성기 예제처럼
외부 의존성이 전혀 없는 경우에는 매우 단순한 테스트 구조를 유지할 수 있다.

// given
final LottoNumberGenerator lottoNumberGenerator = new LottoNumberGenerator();
final int price = 1000;

// when
final List<Integer> lotto = lottoNumberGenerator.generate(price);

// then
assertThat(lotto).hasSize(6);

이 경우 테스트 대상은 오직 하나의 객체이며,
new 키워드만으로 충분히 단위 테스트를 구성할 수 있다.


왜 예약 도메인에서는 같은 방식이 어려울까?

예약 도메인의 서비스 로직은
로또 예제와 구조적으로 다르다.

하나의 기능을 수행하기 위해
여러 협력 객체와 메시지를 주고받기 때문이다.

photoLabRepository.findById(...)
memberUserRepository.findById(...)
reservationSlotRepository.findByPhotoLabIdAndReservationDateAndReservationTimeForUpdate(...)
reservationRepository.save(...)

이 서비스는 다음과 같은 특징을 가진다.

  • Repository를 통해 DB에 접근한다.

  • 트랜잭션과 락 조회가 포함된다.

  • 여러 협력 객체의 상태에 따라 분기 로직이 달라진다.

이런 구조에서는 단순히 객체를 new 해서 테스트를 실행할 수 없다.
테스트가 DB 상태, 트랜잭션 환경, 외부 설정에 영향을 받게 되기 때문이다.

즉, Mock 없이 실제 협력 객체만으로는
순수 객체 기반 단위 테스트를 구성하기 어렵다.


그래서 단위 테스트에서는 Mock을 사용한다

이러한 문제를 해결하기 위해,
단위 테스트에서는 실제 객체 대신 행동이 고정된 가짜 객체(Mock)를 주입한다.

중요한 점은,
Mock을 사용하더라도 테스트의 초점은 여전히 Service에 있다는 것이다.


Mock을 사용했지만, 테스트의 초점은 Service에 있다

아래 테스트는
ReservationCommandService 하나의 개념만을 검증한다.

@ExtendWith(MockitoExtension.class)
class ReservationCommandServiceImplTest {

    @Mock PhotoLabRepository photoLabRepository;
    @Mock MemberUserRepository memberUserRepository;
    @Mock ReservationRepository reservationRepository;
    @Mock ReservationSlotRepository reservationSlotRepository;

    @InjectMocks
    ReservationCommandServiceImpl service;
}

모든 Repository는 Mock으로 대체되었고,
각각의 반환 값은 Given 단계에서 명확하게 정의된다.

given(photoLabRepository.findById(labId))
        .willReturn(Optional.of(lab));

given(memberUserRepository.findById(memberId))
        .willReturn(Optional.of(user));

이를 통해 이 테스트는

  • DB 동작을 검증하지 않고

  • 트랜잭션 구현을 신경 쓰지 않으며

  • 오직
    **“이 서비스가 주어진 상황에서 올바른 결정을 내리는지”**만 확인한다.


Given 지옥을 피하기 위해 적용한 방법

협력 객체가 많은 서비스 테스트에서는
Given 코드가 길어지기 쉽다.

이를 방치하면 테스트의 의도가 흐려진다.

1. Fixture로 객체 생성 책임을 분리했다

var lab = ReservationDomainFixture.photoLab(labId);
var user = ReservationDomainFixture.memberUser(memberId);
var slot = ReservationDomainFixture.slot(lab, 100L, date, time);

테스트에서는
무엇을 검증하는지만 드러나야 한다.

객체 생성 방식은 Fixture로 숨긴다.


2. 결과 중심으로 검증했다

assertThat(response.reservationId()).isEqualTo(777L);

내부 구현이 아니라
최종 결과와 핵심 부작용만 검증한다.


3. 한 테스트는 하나의 개념만 검증했다

void createReservation_정상_슬롯없으면_생성후_예약저장()
  • 메서드 이름 자체가 시나리오

  • 실패 원인이 즉시 드러남

  • 불필요한 assert 제거


Spring Context를 띄우지 않은 이유

이 테스트는 @SpringBootTest를 사용하지 않는다.
즉, Spring Application Context를 띄우지 않는다.

그 결과 다음과 같은 장점이 있다.

  • 테스트 실행 속도가 빠르다 (Fast)

  • 테스트 간 상태 공유가 없다 (Independent)

  • 환경에 관계없이 반복 가능하다 (Repeatable)

이는 FIRST 원칙을 충족하는
단위 테스트에 가까운 구조이다.


정리

  • 단위 테스트는 “Mock이 없는 테스트”가 아니다.

  • 단위 테스트의 핵심은
    하나의 개념을 외부 영향 없이 검증하는 것이다.

  • 외부 환경에 의해 결과가 달라질 수 있는 의존성만 Mock으로 대체한다.

  • Given / When / Then 구조를 통해 테스트의 의도를 드러낸다.

  • Fixture와 명확한 네이밍으로 Given 지옥을 줄인다.

이러한 기준을 지키면,
협력 객체가 많은 서비스 로직에서도
읽기 쉽고 신뢰할 수 있는 단위 테스트를 작성할 수 있다.

More from this blog

[Elasticsearch] Elasticsearch 기본용어와 CRUD 명령어

-elastic ▶ 들어가며 이번 글에서는 Elasticsearch를 공부하면서 가장 먼저 익혀야 하는 기본 용어를 정리하고,직접 코드를 쳐가며 CRUD(Create / Read / Update / Delete) 명령어에 익숙해지는 시간을 가져보려고 한다. Elasticsearch는 처음 보면 생소한 용어가 많아서 막막할 수 있는데,사실 구조적으로는 우리가 익숙한 MySQL과 닮은 부분이 굉장히 많다. 둘 다 데이터베이스라는 큰 틀 안에서 데...

Feb 17, 20264 min read

Jpa N+1 문제, 우리는 이렇게 잡았다 — 1:1 문의 Api 실전 최적화기

코드 리뷰 한 줄에서 시작된 쿼리 최적화 여정 1. 시작 — "일단 돌아가게 만들자" Finders 프로젝트에서 1:1 문의(Inquiry) API를 맡았다. 현상소에 문의를 남기고, 답변을 받고, 목록을 조회하는 — 평범한 CRUD다. "JPA 쓰면 쿼리 안 짜도 되는 거 아니야?" 솔직히 처음엔 그렇게 생각했다. JpaRepository에 findAll, findById 쓰면 끝이니까. // 첫 번째 버전의 목록 조회 (QueryDS...

Feb 11, 20268 min read

[모니터링] Sentry 도입부터 Discord 에러 알림까지 — 서버 감시 시스템 구축기

"서버 죽었는데 아무도 몰랐다"에서 "에러나면 1분 안에 안다"까지 1. 모니터링을 시작한 계기 프론트엔드: "API 안 되는데요?"백엔드: "엥? 언제부터요?"프론트엔드: "...2시간 전부터요?"백엔드: 😱 어느 날 서버가 죽어있었는데 아무도 몰랐다. 그날 이후, 모니터링 시스템 구축을 결심했다. (Issue #102) 2. 모니터링 도구 비교: 뭘 쓸까? 처음에는 여러 도구를 비교했다. 도구무료 티어장점단점 Sent...

Feb 11, 20268 min read

[CI/CD] 수동 태그에서 자동 릴리즈까지 — Git Flow와 Auto Release

🚀 우리 auto-release.yml 바로 보러가기 → 이 글에서 설명하는 워크플로우의 전체 코드를 바로 확인할 수 있다! main에 머지만 하면 버전 태그부터 릴리즈 노트까지 알아서 생긴다 1. 우리의 Git 전략: Git Flow (경량 버전) Finders 프로젝트는 Git Flow 전략을 사용하고 있다. 다만 hotfix나 release 브랜치 없이, 조금 가볍게 운영한다. main ← 운영 서버 (prod) 배포 브...

Feb 11, 20265 min read

[ci/cd] 대학생 팀의 배포 파이프라인 진화기

PR 하나면 끝나는 무중단 배포까지, 삽질의 기록 1. 시작은 단순했다 "서버에 올려야 하는데... 어떻게 하지?" Finders 프로젝트를 시작했을 때, 배포라는 걸 해본 적이 없었다. 그래서 처음에는 이렇게 했다. 1. SSH로 서버 접속 2. git pull 3. ./gradlew build 4. java -jar app.jar 당연히 문제가 생겼다. 빌드하는 동안 서버가 꺼져있음 (프론트: "API 왜 안 돼요?" 🔥) 빌...

Feb 11, 20267 min read
F

Finders Tech Blog

16 posts