고코딩

TDD와 BDD에서 사용되는 given/when/then 행동과 실습 본문

Spring 공부

TDD와 BDD에서 사용되는 given/when/then 행동과 실습

고코딩 2022. 1. 24. 16:47

[Spring Boot] when(), given(), any() 및 BDD 설명

패스트 캠퍼스 강의를 듣던 도중 테스트 구문 작성 코드에서 처음 보는 코드를 보았다.

when(this.todoRepository.save(any(TodoEntity.class)))
                .then(AdditionalAnswers.returnsFirstArg()); //TodoRepository가 save()메소드를 호출해서 TodoEntity값을 받으면 받은 엔티티 값을 반환하도록 설정

원래 given-when-then 의 구조는 알고 있었지만 저렇게 스태틱 함수로 구현되어 있는 건 처음 봐서 이해가 되지 않았다. 여러가지 서치를 해보고 정리를 해본다.

용어 정리

TDD

Test Driven Development의 약자로 테스트가 개발을 주도한다라는 개념으로 사용된다.

TDD는 테스트를 먼저 만들고 테스트를 통과하기 위한 행동들이 모두 개발을 주도하는 것을 목표로 한다.

보통 테스트는 개발이 끝난 후에 하는 과정이라고 생각할 수 있다.

하지만 TDD에서는 테스트 코드를 먼저 만들고 테스트 코드를 통과시키기 위해 개발코드를 만들어가면서 완성해 간다.

예시를 하나 보자

1. Calculator클래스와 CalculatorTest 클래스를 생성한다.

public class Calculator{

}
public class CalculatorTest{
  Calculator calc = new Calculator();
}

2. 내가 원하는 기능(덧셈, plus())이 어떻게 생겼고 뭘 반환하는지 CalculatorTest에 작성한다.

public class Calculator{

}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    int a = 10;
    int b = 20;
    int result = calc.plus(a,b);
  }
}
//현재 Calculator 클래스에는 plus라는 메서드가 존재하지 않기 때문에 실패하게 된다.

3. Calculator 클래스에 해당 메서드를 통과할 수 있는 간단한 클래스를 구현한다.

public class Calculator{
  public int plus(int a, int b){
    return 0;
  }
}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    int a = 10;
    int b = 20;
    int result = calc.plus(a,b);
  }
}
//Calculator의 plus메서드에서는 어떤 값을 반환해도 좋다 일단 컴파일을 성공시키는 것이 목적이다.

4. Calculator클래스에서 어떤 결과를 내어야 할지 CalculatorTest클래스에서 다시 정의한다.

public class Calculator{
  public int plus(int a, int b){
    return 0;
  }
}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    int a = 10;
    int b = 20;
    int result = calc.plus(a,b);
    assertEquals(result, a+b);
  }
}
//assertEquals는 JUnit 5에 존재하는 검증문으로 매개변수 2개가 동일하면 테스트 성공을 의미한다.
//현재는 메서드가 0을 반환하니 실패한다.

5. Calculator클래스에서 해당 테스트를 통과할 수 있는 로직을 구현한다.

public class Calculator{
  public int plus(int a, int b){
    return a+b;
  }
}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    int a = 10;
    int b = 20;
    int result = calc.plus(a,b);
    assertEquals(result, a+b);
  }
}

BDD

Behavior Driven Development의 약자로 TDD에서 따왔기 때문에 TDD추구하는 가치가 크게 다르지 않다.

BDD를 처음으로 생각한 Danial Terhorst-North가 TDD를 수행하고 있던 도중 아래와 같은 생각을 했다고 한다.

TDD하다가 해당 코드를 분석하기 위해서 많은 코드들을 분석해야하고 복잡성으로 인해 '누군가가 나에게 이 코드는 어떤식으로 짜여졌어!' 라고 말을 해줬으면 좋았을 텐데 라고 생각을 하다가 보니 행동 중심 개발을 하면 좋겠다고 생각했다.

BDD는 행동을 기반하여 TDD를 수행하자는 공통의 이해인데, 이를 한 문장으로 하면 다음과 같다.

BDD는 애플리케이션이 어떻게 행동해야 하는지에 대한 공통된 이해를 구성하는 방법이다.

BDD의 행동

BDD의 행동 스펙을 다음과 같다.

  1. Narrative

    모든 테스트 문장을 Narrative하게 되어야 한다. 즉, 코드보다 인간의 언어와 유사하게 구성되어야 한다.

    BDD는 TDD를 수행하려는 어떤한 행동과 기능을 개발자가 더 이해하기 쉽게하는 것이 목적이다.

    모든 테스트 문장은 Given When Then으로 나눠서 작성할 수 있어야 한다.

  2. Given/When/Then

    • Given
      • 테스트를 위해 주어진 상태
      • 테스트 대상에게 주어진 조건
      • 테스트가 동작하기 위해 주어진 환경
    • When
      • 테스트 대상에게 가해진 어떠한 상태
      • 테스트 대상에게 주어진 어떠한 조건
      • 테스트 대상의 상태를 변경시키기 위한 환경
    • Then
      • 앞선 과정의 결과

*즉, 어떤 상태에서 출발(given)하여 어떤 상태이 변화를 가했을 때(when) 기대하는 어떠한 상태가 되어야 한다.(then) *

예시를 하나 보자

public class Calculator{
  public int plus(int a, int b){
    return a+b;
  }
}
public class CalculatorTest{
  Calculator calc = new Calculator();

  @Test
  void plus(){
    //given
    int a = 10;
    int b = 20;

    //when
    int result = calc.plus(a,b);

    //then
    assertEquals(result, a+b);
  }
}

별 차이가 없다. 당연하다. BDD 자체가 TDD에서 더 새로운 개념이 아니라 TDD를 더 잘, 더 멋지게, 더 협조적으로 사용하기 위한 방법이기 때문이다.

이름 창시자 Based 핵심 라이브러리
TDD Kent-Beck 시나리오 기반 테스트가 주도하는 개발 JUnit5, Mockito
BDD Dan North 행동 기반 자연어와 더 가깝게 TDD JUnit5, BDDMockito

BDDMockito를 이용한 BDD

BDD를 실현하기 위해서는 Given/When/Then구조를 잘 사용해야 한다. 앞선 방법 처럼 주석을 이용하여 구분하는 것도 BDD이지만 이보다 더 BDD스럽게 할 수 있는 것이 바로 BDDMockito라이브러리를 이용하는 것이다.

순수 Mockito에서 BDD의 Given/When/Then을 사용하기 위해서 다음과 같이 when(obj) 메서드와 thenReturn() 메서드를 이용하고 verity() 구문을 이용해 검증한다.

이런식으로 특정 상황에 대한 (when과 같이) 우리가 가짜로 결과를 만들어 주는 것을 Stubbing(스터빙) 이라고 한다. 즉 가짜로 수행할 객체를 넣어주는 것이다.

when(phoneBookRepository.contains(momContactName)).thenReturn(false);

phoneBookService.register(momContactName,momPhoneNumber);

verity(phoneBookRepository).insert(momContactName, momPhoneNumber);

이게 일련의 BDD Stubbing과정인데, 뭔가 맞지 않는 부분이 있다. 우리가 알고있던 행동과정은 Given/When/Then인데 위에는 when()/thenReturn()/verity() 이다.

개념적으로 given에 해당되는 Mockito의 when(phoneBookRepository.contains(momContactName)) 의 이름이 when이라 쉽게 헷갈리 수 있다.

이러한 문제점을 BDDMockito의 given()메서드를 이용해 해결할 수 있다.

BDDMockito를 이용하면 Mockito의 when()을 given() 이라는 메서드로 더 정확한 의미 전달을 할 수 잇따.

given(someClass.method()).willReturn()

given() 메서드

Given()은 어떤 메서드가 실행되었을 때(!!!!!)의 테스트를 위한 상황을설정할 수 있다.

다음과 같은 상황을 메서드가 있다고 가정해보자.

public interface MemberRepository extends JpaRepository<Member,Long>{
  boolean existsByEmail(String email);
}
public class MemberService{
  @Autowired
  private MemberRepository memberRepository;

  public boolean isExistEmail(String email){
    return memberRepository.existsByEmail(email);// 존재하면 true, 존재하지 않으면 false 반환
  }
}

그럼 아래와 같은 테스트 코드를 구성할 수 있다.

public interface MemberRepository extends JpaRepository<Member,Long>{
  boolean existsByEmail(String email);
}
public class MemberService{
  @Autowired
  private MemberRepository memberRepository;

  public boolean isExistEmail(String email){
    return memberRepository.existsByEmail(email);// 존재하면 true, 존재하지 않으면 false 반환
  }
}
public class MemberServiceTest{
  @MockBean
  private MemberService memberService;
  private final MemberRepository memberRepository = mock(MemberRepository.class);

  @BeforeEach
  void stubbing(){
    given(memberRepository.exitsByEmail(any(String.class))).willReturn(false);
  }
}

이 중에서 아래의 코드를 분석해보자

given(memberRepository.exitsByEmail(any(String.class))).willReturn(false);

  • memberRepository.existsByEmail - Mocking할 메서드
  • any(String.class) - 메서드의 파라미터
  • .willReturn(false); - 해당 메서드가 반환하는 값

위의 Stubbing 문장에서 3가지 의미를 담고있다.

Mocking 할 메서드

Unit Test에서 중요한 것은 테스트하려는 대상의 고립이다.

테스트 대상을 고립한다는 뜻은, 테스트 대상에 연관된 다른 객체들은 관여하지 않도록 우리가 가짜 객체를 넣어줘야 한다는 이야기이다

이렇게 테스트 대상을 고립하기 위해서 Mockito의 mock()를 이용하였고, 테스트 대상이 특정 결과를 수행하기 위해 연관된 객체의 연산을 주입해주면 된다.

MemberService에는 현재 isExistsByEmail 메서드가 존재하고 해당 메서드 내부에 MemberRepository 인스턴스가 existsByEmail 연산을 수행하고 있으므로 우리는 MemberRepository의 existsByEmail을 가짜로 주입해주면 된다.

메서드의 파라미터

existsByEmail 이라는 메서든는 String으로 email을 받고 있다. 그럼 선택지가 주어진다

  • 모든 값을 받았을 때의 행동 정의하기
  • 특정 값을 받았을 때의 행동 정의하기

모든 값을 받았을 때의 행동 정의하기, any()

모든 객체가 들어왔을 때의 행동은 any(Object object)를 이용할 수 있다.

여기서는 any(String.class) 는 String인 모든 객체가 가능하다는 뜻이다. String 관련 any() 메서드는 여럿 존재한다.

결국 any(Object object) 의 의미는 Object 타입의 모든 객체라는 뜻이다. any() 내부적으로 Type을 읽고 판단하도록 한다.

  • anyString() : 어떤한 문자열
  • anyLong() : 어떠한 Long
  • anyBoolean() : 어떠한 Boolean값
  • any() : 모든 객체

특정 값을 받았을 때의 행동 정의하기 , eq()

만약 특정한 String 값이 들어와야 한다면 any()로는 판단할 수 없다. 그럴 때 사용하는게 바로 eq() 메서드이다.

  • eq() : 동일한 값 혹은 객체의 행동

해당 메서드를 수행했을 때 반환하는 값

위와 같이 다양한 방법으로 행동을 정의했다. 그럼 이제 행동에 대한 적절한 반환을 해줘야 한다.

행동을 반환할 때는 크게 3가지 방법이 존재한다.

  • willReturn()
  • will()
  • willThrow()

willReturn()

public class MemberServiceTest{
  @BeforeEach
  void stubbing(){
    given(memberRepository.existsByEmail(any(String.class))).willReturn(false);
  }

  @Test
  void exists_valid(){
    boolean isExist = memberService.isExistEmail("valid@gmail.com");

    assertTrue(isExist);
  }
}

will() + invocation

will()은 willReturn과 조금 다르다

will()에서는 invocation을 통해서 새로운 객체를 반환하거나 아예 새로운 행동을 반환할 수 있다.

invoke : 호출하다.

public class MemberServiceTest{
  @BeforeEach
  void stubbing(){
    given(memberRepository.findById(anyLong))
      .will(invocation -> {
        Member member = invocation.getArgument(0);
        return MemberData.builder()
                                          .email("test123@gmail.com")
                                          .isFound(true)
                                          .build();
      });
  }
  @Test
  void exists_valid(){
    MemberData memberData = memberService.findMember(1L);

    assertNotNull(memberData);
  }
}

willThrow()

예외를 던지는 함수이다.

public class MemberServiceTest{
  @BeforeEach
  void stubbing(){
    given(memberRepository.existsByEmail(eq("hello@gmail.com"))).willThrow(MemberDuplicationException("exists"))
  }
  @Test
  void exists_valid(){
    MemberDuplicationException exception = assertThrow(
        MemberDuplicationException.class, () -> memberService.isExistEmail("valid@gmail.com"));

    assertEquals(exception.getMessage(), "exists");
  }
}

그럼 몇가지 예제 코드를 보면서 테스트 코드를 읽어보자!!

@ExtendWith(MockitoExtension.class)
class TodoServiceTest {

    @Mock
    private TodoRepository todoRepository;

    @InjectMocks    // 위 todoRepository Mock을 주입받아서 사용할 todoService 정의
    private TodoService todoService;

    @Test
    void add() {
        when(this.todoRepository.save(any(TodoEntity.class)))
                .then(AdditionalAnswers.returnsFirstArg()); //TodoRepository가 save()메소드를 호출해서 TodoEntity값을 받으면 받은 엔티티 값을 반환하도록 설정

        TodoRequest expected = new TodoRequest();
        expected.setTitle("Test Title");

        TodoEntity actual = this.todoService.add(expected);

        assertEquals(expected.getTitle(), actual.getTitle());
    }
}

우리가 해석해야 할 문장은 when(this.todoRepository.save(any(TodoEntity.class))).then(AdditionalAnswers.returnsFirstArg()); 이다.

일단 when()은 Mockito에 정의된 함수로 BDD관련 함수가 아니다 하지만 의미는 given행동과 똑같으니 해석할 수 있다.

  • when(this.todoRepository.save()) : todoRepository에 정의된 save() 메서드가 호출되는 상태라는 뜻이다.
  • save(any(TodoEntity.class)) : save() 메서드가 TodoEntity타입의 파라미터로 불린다는 뜻이다.

결국 둘의 의미를 합해보면 todoRepository에 정의된 save()메서드가 TodoEntity 타입으로 한 파라미터를 가지채로 호출되는 상태 라는 뜻이다.

.then()은 OngoingStubbing 인터페이스에 정의된 함수이다. 결국 then의 의미로 해석하면 되는데 앞선 과정의 결과에 관련된 함수라고 생각하면 된다.

이제 AdditonalAnswers.returnFirstArg()의 의미가 궁금한데 command+ B(Mac 기준) 를 눌러서 설명을 보자

public class AdditionalAnswers {
    /**
     * Returns the first parameter of an invocation.
     *
     * <p>
     *     This additional answer could be used at stub time using the
     *     <code>then|do|will{@link org.mockito.stubbing.Answer}</code> methods. For example :
     *
     * <pre class="code"><code class="java">
     * given(carKeyFob.authenticate(carKey)).will(returnsFirstArg());
     * doAnswer(returnsFirstArg()).when(carKeyFob).authenticate(carKey);
     * </code></pre>
     * </p>
     *
     * <p>
     * This methods works with varargs as well, mockito will expand the vararg to return the argument
     * at the given position. Suppose the following signature :
     *
     * <pre class="code"><code class="java">
     * interface Person {
     *     Dream remember(Dream... dreams);
     * }
     *
     * // returns dream1
     * given(person.remember(dream1, dream2, dream3, dream4)).will(returnsFirstArg());
     * </code></pre>
     *
     * Mockito will return the vararg array if the first argument is a vararg in the method
     * and if the return type has the same type as the vararg array.
     *
     * <pre class="code"><code class="java">
     * interface Person {
     *     Dream[] remember(Dream... otherDreams);
     * }
     *
     * // returns otherDreams (happens to be a 4 elements array)
     * given(person.remember(dream1, dream2, dream3, dream4)).will(returnsFirstArg());
     * </code></pre>
     * </p>
     *
     * @param <T> Return type of the invocation.
     * @return Answer that will return the first argument of the invocation.
     *
     * @since 1.9.5
     */
    public static <T> Answer<T> returnsFirstArg() {
        return (Answer<T>) new ReturnsArgumentAt(0);
    }
}

해석을 해보자면

invocation의 첫번째 파라미터를 리턴해준다.

만약 given(person.remember(dream1,dream2,dream3)).will(returnFirstArg())는 dream1을 리턴해 준다

우리 코드에서는 save()를 호출할때 넣었던 파라미터를 리턴해준다는 뜻이다.

결국 총 해석은 todoRepository에 정의된 save()메서드가 TodoEntity 타입으로 한 파라미터를 가지채로 호출되면 넣었던 파라미터를 리턴해준다를 stubbing한것이다.


참조 : JUnit5 BDDMockito로 알아보는 TDD와 BDD의 차이 및 BDD 실습