BDD 스타일의 테스트 코드 작성 가이드

2025. 11. 11. 00:02·Java

gemini로 생성한 이미지

 

사내에서 프로젝트를 수행하며 테스트 코드의 중요성을 동료들에게 설파했지만, 테스트 작성에 익숙하지 않은 동료들도 있어 가이드를 작성할 겸 정리하며 모호했던 개념들이 정리했다.

 

테스트 피라미드를 이해하고 있다면 작성해야 할 테스트와 그렇지 않은 테스트를 좀 더 빨리 알 수 있는데, 이 글의 목적에서 벗어나는 내용도 많으므로 다른 정리 글에서 정리하겠다.

용어 정리

경험상 용어를 정확히 알고 작성하면 전체적인 코드의 가독성이 좋아짐을 느꼈다.

내가 작성하고 있는 코드 한 줄이 Stub인지, Mock인지를 모르고 작성하면, 읽는 사람이 이해하는데 시간과 에너지를 더 많이 써야한다.

그래서 자주 사용되는 용어를 간단하게 정리하고 가이드를 보자.

용어는 정리가 잘 된 이 블로그를 참고했다.


Test Double (테스트 더블)

실제 테스트 대상 객체가 의존하는 협력 객체(외부 시스템, 복잡한 로직 등)를 대신해서 테스트를 격리하고 진행할 수 있도록 만든 객체

 

스턴트 더블(위험한 액션 씬을 대신해서 수행하는 스턴트 배역)에서 유래한 단어로, xUnit Test Pattern의 저자인 제라드 메스자로스가 만든 용어. Dummy, Fake, Stub, Spy, Mock으로 세분화할 수 있다.

Dummy

객체가 필요하지만 사용되지는 않을 때 사용

Fake

실제 구현을 단순화한 대체 구현체. 예를 들어, 실제 DB 대신 인메모리 DB를 사용하는 경우가 있다.

Stub

미리 준비된 결과를 제공하는 객체.

 

인터페이스 또는 기본 클래스가 최소한으로 구현된 상태이며, 하드 코딩된 정해진 값을 반환하도록 프로그래밍됨

Spy

Stub의 역할을 하면서 호출된 정보를 기록하는 객체.

 

실제 객체를 감싸거나(래핑 하거나) 상속해서 실제 객체처럼 행동하지만, 메서드가 호출된 횟수나 전달된 인자 등의 정보를 기록한다. 테스트가 끝난 후 이 기록을 검사해서 상호작용을 검증한다.

Mock

호출에 대한 기대를 명세하고, 그 충족 여부를 검증할 때 사용한다.

 

Stub, Spy의 기능을 모두 가지며, 테스트 시작 전에 어떤 인자로 몇 번 호출되어야 하는지 등 기대(Expectation)를 미리 정의한다. 테스트 종료 후 이 명세가 충족되지 않으면 테스트가 실패한다.


BDD 스타일이란?

BDD는 Behaviour-Driven Development(행위 주도 개발)의 약자다.

핵심은 테스트 대상의 행동이 야기하는 상태의 변화가 의도한 결과인지 테스트하는 것이다.

  • 권장하는 작성 스타일: 시나리오를 통해 테스트 대상의 기능 또는 책임의 동작을 검증하는 것을 권장한다.
    • 함수의 단위를 테스트하는 것을 권장하지 않는다.
    • 작성되는 시나리오는 비개발자가 봐도 이해할 수 있어야 한다.
    • 하나의 시나리오는 Given, When, Then 구조를 기본 패턴으로 한다.
      • Feature: 테스트 대상의 기능/책임을 명시
      • Scenario: 테스트 목적을 설명
      • Given: 시나리오 진행을 위해 필요한 값을 설정 (테스트 대상의 환경을 설정)
      • When: 시나리오 진행을 위해 필요한 조건을 설정 (테스트 대상의 행동을 요구)
      • Then: 시나리오 완료 시 보장해야 하는 결과를 명시 (기대하는 결과를 검증)

테스트 코드 작성 원칙

Test WHAT (behavior), not HOW (implementation)
  • 시스템이 ‘무엇(what)’을 해야 하는지 (행동)에 초점을 맞추고, 내부가 ‘어떻게(how)’ 구현되었는지는 테스트의 주 목표가 아니라는 의미다.
  • 이 원칙을 따라야 하는 이유는 두 가지를 들 수 있다.
    1. 구현 변경에 대한 유연성: 구현 방식이 달라지더라도 요구사항이 동일하다면 테스트 케이스는 유효할 수 있다.
    2. 요구사항 중심의 테스트: 개발자의 구현 방식이 아니라, 사용자가 기대하는 요구사항 중심의 테스트를 보장한다.

BDD 스타일로 테스트 코드 작성하기

예제: 시험 결과를 입력하면 합격 기준에 따라 심사를 거쳐 자격증을 발급하는 시스템이 있다. 이 시스템에선 시험 결과를 입력하는 이벤트가 발행되면, 합격 기준에 속하는 이벤트만 자격증 취득 심사를 생성한다.

BDD 테스트 패턴: 테스트 대상의 행동이 야기하는 상태 변화 검증하기

테스트 대상 (feature): 테스트 대상의 기능/책임을 명시한다.

  • 예제에선 필기평가 결과 입력 완료 이벤트를 발행했을 때 합격 기준에 해당하는 이벤트만 자격 취득 심사를 생성하는 기능

시나리오 (scenario): 테스트 목적을 설명한다.

  • 예제에선 필기평가 결과(ExamResult)를 입력했을 때, 그 결과가 합격 기준에 해당하면 자격 취득 심사(InitialReview)가 생성되는 동작을 검증함을 설명한다.

given-when-then 패턴

  • given: 시나리오 진행을 위해 필요한 초기 조건 등을 설정한다.
    • 예제에선 합격과 불합격들을 포함하는 필기평가 결과 (ExamResult)들을 입력한다(=엔티티를 생성한다).
      1. 합격 기준: 총 점수 70점 이상 + 응시완료 상태
      2. 불합격 기준: 1) 합격 기준 점수 미달이거나, 2) 부정행위 등에 의해 응시무효이거나, 3) 아예 응시하지 않은 경우
      3. 필기평가 결과를 최초로 입력하는 상황이며, 필기평가와 연결된 자격 취득 심사는 존재하지 않는다.
  • when: 테스트 대상에게 특정 행동을 요구하거나 이벤트를 발생시킨다.
    • 예제에선 필기평가 결과 입력 이벤트를 발행한다.
  • then: 시나리오 완료 시 보장해야 할 결과를 명시한다.
    • 예제에선 합격 기준에 해당하는 케이스만 자격 취득 심사를 생성하는지 검증한다.

구체적인 예제

1. JUnit 5를 활용한 테스트 대상과 시나리오 작성

@Test
@DisplayName("응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    
}

2. given-when-then 패턴으로 작성

given: 합격 및 불합격 결과들이 준비되었을 때

@Test
@DisplayName("응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: 합격 및 불합격 결과들이 준비
    /* Phase 1-1. 테스트 진행을 위한 Dummy 설정: 원서 접수 생성 */
    ExamApplication examApplication = mock(ExamApplication.class); // 실제 기능을 하지 않는 Dummy 객체

    /* Phase 1-2. 테스트 진행을 위한 Stub 설정: 협력 관계의 대상들의 행동 설정 */
    given(reviewRepository.findByExamResultId(any())) // 필기평가 최초 입력 당시엔 연결된 자격 취득 심사가 존재하지 않음
            .willReturn(Optional.empty());

    /* Phase 1-3. 테스트 대상을 호출할 때 전달하는 Mock 설정: 필기평가 결과 설정 */
    // case 1. 합격 케이스 - 시험에 응시했으며, 시험 결과가 합격 -> 자격 취득 심사 생성
    ExamResult passed = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(90)
            .build();

    // case 2. 불합격 케이스 - 시험에 미응시 -> 자격 취득 심사 생성 X
    ExamResult notPassed1 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .build();

    // case 3. 불합격 케이스 - 응시 무효 -> 자격 취득 심사 생성 X
    ExamResult notPassed2 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.INVALID)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(100)
            .build();

    // case 4. 불합격 케이스 - 기준 점수 미달 -> 자격 취득 심사 생성 X
    ExamResult notPassed3 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.FAIL)
            .totalScore(60)
            .build();

    // 필기시험 결과 조회에 대한 Stub 설정
    given(examResultRepository.findById(passed.getId())).willReturn(Optional.of(passed));
    given(examResultRepository.findById(notPassed1.getId())).willReturn(Optional.of(notPassed1));
    given(examResultRepository.findById(notPassed2.getId())).willReturn(Optional.of(notPassed2));
    given(examResultRepository.findById(notPassed3.getId())).willReturn(Optional.of(notPassed3));

    // 필기평가 결과 입력 이벤트 생성 (최초 입력=FIRST_SUBMISSION)
    ExamResultCompletedEvent event = ExamResultCompletedEvent.of(
            List.of(passed.getId(), notPassed1.getId(), notPassed2.getId(), notPassed3.getId()),
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );
}

 

when: 필기평가 결과 입력 이벤트가 발행되면

@Test
@DisplayName("응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given 생략
		
    // when: 필기평가 결과 입력 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);
}

 

then: 오직 합격 결과에 대해서만 심사 저장(save)이 단 한 번 일어난다.

@Test
@DisplayName("응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given 생략
	
    // when 생략
		
    // then: 합격 기준에 해당하는 케이스만 자격 취득 심사를 생성하는지 검증한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
    
    // 이 부분은 '무엇(what)'이 아닌 '어떻게(how)'에 가까우르모 분리 또는 생략한다.
    then(reviewRepository).should(times(4)).findByExamResultId(any(UUID.class));
    then(examResultRepository).should(times(4)).findById(any(UUID.class));
}

전체 코드

@Test
@DisplayName("응시상태가 '응시완료'이면서, 시험 결과가 '합격'일 때만 자격취득심사가 생성된다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: 합격과 불합격들을 포함하는 필기평가 결과 (ExamResult)들을 입력
    /* Phase 1-1. 테스트 진행을 위한 Dummy 설정: 원서 접수 생성 */
    ExamApplication examApplication = mock(ExamApplication.class); // 실제 기능을 하지 않는 Dummy 객체

    /* Phase 1-2. 테스트 진행을 위한 Stub 설정: 협력 관계의 대상들의 행동 설정 */
    given(reviewRepository.findByExamResultId(any())) // 필기평가 최초 입력 당시엔 연결된 자격 취득 심사가 존재하지 않음
            .willReturn(Optional.empty());

    /* Phase 1-3. 테스트 대상을 호출할 때 전달하는 Mock 설정: 필기평가 결과 설정 */
    // case 1. 합격 케이스 - 시험에 응시했으며, 시험 결과가 합격 -> 자격 취득 심사 생성
    ExamResult passed = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(90)
            .build();

    // case 2. 불합격 케이스 - 시험에 미응시 -> 자격 취득 심사 생성 X
    ExamResult notPassed1 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .build();

    // case 3. 불합격 케이스 - 응시 무효 -> 자격 취득 심사 생성 X
    ExamResult notPassed2 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.INVALID)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(100)
            .build();

    // case 4. 불합격 케이스 - 기준 점수 미달 -> 자격 취득 심사 생성 X
    ExamResult notPassed3 = ExamResult.builder()
            .id(UUID.randomUUID())
            .examApplication(examApplication)
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.FAIL)
            .totalScore(60)
            .build();

    // 필기시험 결과 조회에 대한 Stub 설정
    given(examResultRepository.findById(passed.getId())).willReturn(Optional.of(passed));
    given(examResultRepository.findById(notPassed1.getId())).willReturn(Optional.of(notPassed1));
    given(examResultRepository.findById(notPassed2.getId())).willReturn(Optional.of(notPassed2));
    given(examResultRepository.findById(notPassed3.getId())).willReturn(Optional.of(notPassed3));

    // 필기평가 결과 입력 이벤트 생성 (최초 입력=FIRST_SUBMISSION)
    ExamResultCompletedEvent event = ExamResultCompletedEvent.of(
            List.of(passed.getId(), notPassed1.getId(), notPassed2.getId(), notPassed3.getId()),
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );

    // when: 필기평가 결과 입력 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);

    // then: 합격 기준에 해당하는 케이스만 자격 취득 심사를 생성하는지 검증한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
    
    // 이 부분은 '무엇(what)'이 아닌 '어떻게(how)'에 가까우르모 분리 또는 생략한다.
    then(reviewRepository).should(times(4)).findByExamResultId(any(UUID.class));
    then(examResultRepository).should(times(4)).findById(any(UUID.class));
}

3. 테스트 결과 (PASS)

4. 테스트 코드 리팩토링

테스트 코드는 프로덕션 코드와 똑같이 중요하다. 따라서 누구나 읽기 쉽게 작성하고, 유지 관리를 해야 한다.

 

작성된 코드에는 문제가 몇 가지가 있다.

  1. given 부분: 테스트 더블을 작성하는 코드들이 지저분해서 가독성이 떨어진다. 반복적으로 생성하는 부분을 메서드로 분리하면 가독성이 개선된다.
  2. then 부분: 테스트 대상(합격 기준을 만족하는 이벤트만 심사를 생성)과 연관되지만 무엇(what)이 아닌 어떻게(how)에 가까운 부분들이 있다. 해당 검증은 별도의 테스트로 분리하거나 생략해서 가독성을 개선할 수 있다.

개선된 코드

@Test
@DisplayName("필기평가 결과가 '응시완료' 및 '합격'일 때만 자격취득심사가 생성되어야 한다.")
void should_create_reviews_only_for_completed_and_passed_exam_results() {
    // given: (준비) 합격/불합격 케이스를 포함하는 필기평가 결과들이 준비되고, 기존 심사가 없도록 설정
    ExamResult passedResult = createPassedExamResult(90); // 합격 케이스
    ExamResult notTakenResult = createNotTakenExamResult(); // 불합격 케이스 1: 미응시
    ExamResult invalidResult = createInvalidExamResult(100); // 불합격 케이스 2: 응시 무효
    ExamResult failedResult = createFailedExamResult(60); // 불합격 케이스 3: 기준 점수 미달

    // 협력 객체 설정: 필기평가 결과 ID로 조회 시 해당 결과 반환, 기존 심사는 없다고 설정
    setUpExamResultStub(passedResult, notTakenResult, invalidResult, failedResult);
    setUpNoExistingReviewStub();

    // 필기평가 결과 입력 이벤트 생성
    ExamResultCompletedEvent event = createEvent(
        List.of(passedResult.getId(), notTakenResult.getId(), invalidResult.getId(), failedResult.getId())
    );

    // when: (행동) 필기평가 결과 입력 완료 이벤트를 발행한다.
    eventListener.handleExamResultCompleted(event);

    // then: (결과 검증) 합격 케이스에 대해서만 InitialQualificationReview가 생성(저장)되었는지 확인한다.
    // **핵심 검증:** 오직 한 번 (합격 케이스)만 InitialQualificationReview 객체가 저장되어야 한다.
    then(reviewRepository).should(times(1)).save(any(InitialQualificationReview.class));
}

// BDDMockito를 사용하여 given() 메소드를 static import했다고 가정
private ExamResult createPassedExamResult(int score) {
    // 합격 기준 케이스: COMPLETED, PASS, 70점 이상
    return ExamResult.builder()
            .id(UUID.randomUUID())
            .participationStatus(ParticipationStatus.COMPLETED)
            .examOutcome(ExamOutcome.PASS)
            .totalScore(score)
            .examApplication(mock(ExamApplication.class)) // Dummy 객체
            .build();
}

private ExamResult createNotTakenExamResult() {
    // 불합격 케이스 1: NOT_TAKEN
    return ExamResult.builder()
            .id(UUID.randomUUID())
            .participationStatus(ParticipationStatus.NOT_TAKEN)
            .examOutcome(ExamOutcome.NOT_TAKEN)
            .totalScore(null)
            .examApplication(mock(ExamApplication.class)) // Dummy 객체
            .build();
}

private void setUpExamResultStub(ExamResult... results) {
    // 필기시험 결과 조회(findById)에 대한 Stub 설정 일괄 처리
    for (ExamResult result : results) {
        given(examResultRepository.findById(result.getId()))
                .willReturn(Optional.of(result));
    }
}

private void setUpNoExistingReviewStub() {
    // 자격 취득 심사 존재 여부(findByExamResultId)에 대한 Stub 설정 (항상 없다고 가정)
    given(reviewRepository.findByExamResultId(any()))
            .willReturn(Optional.empty());
}

private ExamResultCompletedEvent createEvent(List<UUID> resultIds) {
    // 이벤트 생성
    return ExamResultCompletedEvent.of(
            resultIds,
            ExamResultSubmissionFlag.FIRST_SUBMISSION
    );
}


정리하며

테스트를 작성할 때 항상 고민되는 부분은 두 가지 정도였다.

 

1. 하나는 어디서부터 Stub으로 만들고, 어디까지 Mock으로 두느냐에 대한 것이다.

2. 다른 하나는 모두가 읽기 쉬운 깔끔한 코드를 작성하는 방법이다.

 

1번에 대한 고민은 동료의 힌트로 알게 된 두 분파 Mockist와 Classicist의 철학을 찾아보며 갈피를 잡게 되었고, 빠르게 실행해 피드백을 얻을 수 있도록 협력 관계의 객체들을 Mocking 하는 방식을 채택해 해결했다. 그럼에도 여전히 실험을 하며 더 읽기 쉽고 명확한 테스트가 되도록 많이 작성하며 경험적으로 터득해야 한다.

 

2번에 대한 고민은 많은 테스트를 작성하고, 검토하며, 모범 사례들을 찾아보는 것이 도움이 됐다. 그러다 보면 자연스럽게 테스트 프레임워크에 대한 숙련도가 올라가기 마련이고, 동료들과 작성된 코드로부터 피드백을 얻어 개선해 나가는 것이 중요하다.

 

여담으로... 프로젝트를 진행하며 고객의 요구사항이 자주 변경되는 파트를 담당했을 때다.

구현 방식이 조금이라도 달라지면 기존 테스트들이 회귀 테스트에서 와장창 깨지고, 하나하나 수정하느라 일정 압박에 시달렸었다.

테스트 코드가 있어서 내가 작성한 기능이 제대로 동작함을 확신할 수 있는데 도움이 되긴 했지만... 이렇게 유지 관리가 힘들었던 경험이 쌓이다 보니 일정 압박이 심해질수록 테스트 코드를 스킵하고 구현할 때도 있었다.

그때 테스트 코드를 작성하는 가이드가 있었더라면... 어떻게(how)가 아니라 무엇(what)을 해야 하는지에 초점을 맞추는 것을 염두에 두고 작성했더라면... 이런 생각이 프로젝트 내내 마음 한편에 있었는데, 이번 기회에 테스트에 익숙하지 않은 동료들에게 공유할 글을 정리할 수 있어서 뿌듯하다.

'Java' 카테고리의 다른 글

Java Memory Model(JMM)과 동시성 규칙  (2) 2025.01.22
JVM 아키텍처 정리  (0) 2025.01.22
[Java] opencsv 로 CSV 읽고 저장하기  (2) 2024.07.09
[Java] Optional에서 map과 flatMap의 차이점 쉽고 빠르게 이해하기  (0) 2024.02.28
'Java' 카테고리의 다른 글
  • Java Memory Model(JMM)과 동시성 규칙
  • JVM 아키텍처 정리
  • [Java] opencsv 로 CSV 읽고 저장하기
  • [Java] Optional에서 map과 flatMap의 차이점 쉽고 빠르게 이해하기
옐리yelly
옐리yelly
  • 옐리yelly
    개발 갤러리
    옐리yelly
  • 전체
    오늘
    어제
    • 모든 글 보기 (86)
      • Project (22)
      • Java (5)
      • Spring (8)
      • Kubernetes (6)
      • Docker (2)
      • JPA (3)
      • Querydsl (2)
      • MySQL (9)
      • ElasticSearch (7)
      • DevOps (4)
      • Message Broker (3)
      • Git & GitHub (2)
      • Svelte (1)
      • Python (8)
        • Python Distilled (4)
        • Anaconda (1)
        • Django (0)
        • pandas (3)
      • Algorithm (1)
      • Computer Science (0)
      • 내 생각 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    pymysql
    포텐데이
    pandas
    mybatis
    nks
    프로젝트
    svelte
    querydsl
    JPA
    MySQL
    비사이드
    리팩토링
    성능 테스트
    Message Broker
    Python
    gitops
    docker
    OOP
    argocd
    데드락
    devops
    예약 시스템
    k8s
    Spring
    blue-green 배포
    커넥션 풀
    RabbitMQ
    ncloud
    elasticsearch
    Project
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
옐리yelly
BDD 스타일의 테스트 코드 작성 가이드
상단으로

티스토리툴바