🎁 모든 강의 30% + 무료 강의 선물🎁

[4주차 발자국] 보다 더 나은 테스트를 위해

이번 주차는 시간이 많이 부족했던 관계로, 강의를 통한 느낀점 위주로 작성하겠습니다.

인프런 ‘Readable Code: 읽기 좋은 코드를 작성하는 사고법’을 수강한 후, 작성한 내용입니다.

📌 강의 내용

Presentation Layer

기존에 프로젝트에서 Presentation Layer 테스트는 건너뛴 경우가 많았다. 대부분 Business Layer까지만 테스트를 작성했고 사실 어떻게 Presentation Layer 테스트를 작성해야할지 잘 몰라서 안했던 것도 컸다,

이번 강의를 통해 MockMVC을 사용하여 Presentation Layer 테스트에 대해 익힐 수 있었고, 프로젝트에도 적용해봐야겠다.

Mock을 마주하는 자세

Mock은 주로 테스트 하는 대상에 대해 집중하기 위해 사용한다. 예를 들어, 외부 클라이언트에 의존하는 기능이 있을 때 실제 이 기능을 사용하면서까지 테스트할 필요는 없다. 이러한 상황에서 Mock을 사용하게 된다.

우리는 주로 테스트를 작성할 때 BDD 스타일로 작성하게 되는데, Mock을 사용하게 되면 given 절에 when().thenReturn()을 사용하게 된다. 이러한 상황에 BDDMockito를 사용하게 되면 given 절에서 given().thenReturn() 형식으로 훨씬 자연스럽게 구성할 수 있다.

Mock을 사용하는 관점에서 Classicist와 Mockist가 존재한다. 간단하게 말하면, Classicst는 Mock 사용을 최소하하여 꼭 필요한 경우에만 사용하고, Mockist는 따로따로 다 테스트를 할 수 있기에 보장된 기능은 Mocking 처리를 통해 빠르게 테스트를 한다는 입장이다.

두 관점에 대해 확실한 정답은 없다. 소프트웨어의 안정성을 고려하면 Classicst의 입장의 손을 들 수 있다. 프로덕션 코드에서 런타임 시점에 일어날 일을 정확하게 Stubbing 하는 것은 확실하지 않고 모르는 일이다.

더 나은 테스트를 작성하기 위한 구체적 조언

테스트도 하나의 코드다. 테스트를 작성할 때에도 클린 코드에서 배웠던 내용을 상기시키면서 작성해보자. 또한 테스트 코드에서 테스트하기 어려운 영역이 있다면, 이 것이 리팩토링 또는 영역 분리의 신호는 아닌지 생각하자.

또한 @BeforeEach 등으로 테스트마다 중복되는 코드를 분리할 수도 있는데, 중복된다고 모두 분리하는 것은 아니어야 한다. 중복이라고 느껴져서 분리하면 테스트에 대한 이해에 걸림돌이 될 수 있다. 예를 들어, 댓글에 관한 테스트인데 댓글 생성을 @BeforeEach로 분리하면, 테스트를 이해하기에 어려울 수 있다.

테스트를 확인하기 위해 모든 테스트를 한꺼번에 돌리면, 스프링이 여러 번 띄워지는 경우를 확인할 수 있다. 이는 테스트 마다 설정이 달라서 그런 것인데, 테스트 환경을 통합하여 스프링 서버가 띄워지는 횟수를 줄여 더 빠르게 테스트하고 확인할 수 있게 하자.

  • private 메서드 테스트는 어떻게 하나요?

    • private 메서드 테스트는 할 필요는 없고 해서도 안된다.

    • 만약 그런걸 느낀다면, 객체를 분리할 시점인지 고민해보자!

  • 테스트에서만 필요한 메서드가 생겼는데, 프로덕션 코드에서는 필요없다면..?

    • 만들어도 되지만, 보수적으로 접근하자.

    • 어떤 객체가 마땅히 가져도 될만한 행위이고, 미래에도 충분히 사용될 수 있는 성격의 메서드 정도는 가능하다.

Appendix

새로운 라이브러리를 사용하다보면, 기능에 익숙하지 않다. 보통 검색을 통해 해당 라이브러리를 익히거나 하는데 이떄 테스트를 활용할 수 있다. 테스트 코드를 통해 라이브러리에 대한 행동을 정의하고 검증하는 과정을 통해 구체적인 동작과 기능을 학습할 수 있다.

테스트 코드로 API 문서를 작성할 수 있다. Spring REST Docs인데, 사실 전에도 많이 들어봤던 이름이다. 하지만 Swagger가 간단하고 편하게 작성할 수 있으므로 자주 사용했는데, Swagger를 사용해보면 프로덕션 코드가 지저분해진다. Presentation Layer에 Swagger 관련 코드가 붙으면서 지저분해지면서 단점을 크게 느낀 경험이 있다. 다음엔 Spring REST Docs를 꼭 활용하자.

📌 미션

레이어 아키텍처의 테스트

영속성 레이어

영속성 레이어?

  • DB에 접근하는 계층

  • 비즈니스 로직이 들어가지 않은 순수하게 데이터에 대한 처리 및 조회를 수행

영속성 레이어의 테스트

  • 무엇을 확인해야할까?

    • 원하는 데이터에 정확히 접근하는지

    • 쿼리가 길어졌을 때, 내가 원하는 데이터에 맞게 작성되었는지

  • 어떻게 테스트를 해야할까?

    • 영속성 계층이 의존하는 계층이 대부분 상황에서 없기 때문에 단위 테스트 형식으로 진행

비즈니스 레이어

비즈니스 레이어?

  • 비즈니스 로직이 전개되는 계층

    • 영속성 레이어가 사용된다.

    • 도메인 개념이 적용

    • 트랜잭션 개념 적용

비즈니스 레이어의 테스트

  • 하나의 트랜잭션을 보장하는지 확인

  • 비즈니스 로직이 정확히 수행되는지 확인

  • 여러 케이스에 대해 테스트하자.

프레젠테이션 레이어

프레젠테이션 레이어란?

  • 외부 세계와 가장 가까운 계층

    • 요청과 관련한 데이터를 받는다.

    • 요청에 대한 데이터를 전달한다.

프레젠테이션 레이어의 테스트

  • 요청에서 건너온 값들에 대한 검증

    • 도메인 규칙을 제외한 간단한 검증

  • 상황에 대한 정확한 응답이 반환되는지 확인

@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks

Mock과 Spy의 차이

  • Mock: 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체

  • Spy: Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체

    • 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수도 있다.

Stub이란?

테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체. 그 외에는 응답하지 않는다.

여기서 그러면 ‘Mock이랑 Stub이랑 무슨 차이지? 같은 내용같은데?’라는 의문이 들 수 있다.

https://inf.run/XaZqk

stub은 상태 검증, mock은 행위 검증과 관련되어 있다.

public interface MailService {
  public void send (Message msg);
}
public class MailServiceStub implements MailService {
  private List<Message> messages = new ArrayList<Message>();
  public void send (Message msg) {
    messages.add(msg);
  }
  public int numberSent() {
    return messages.size();
  }
}

// 테스트
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub();
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }

Stub은 메일을 몇 번 보냈는지를 나타내는 상태를 검증한다.

// 테스트
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class);
    Mock mailer = mock(MailService.class);
    order.setMailer((MailService) mailer.proxy());

    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());
  }
}

Mock은 행위를 중심으로 검증한다.

@Mock, @MockBean

  • @Mock

    • 어노테이션이 붙은 객체를 Mock 객체로 생성

  • @MockBean

    • 어노테이션이 붙은 객체를 Mock 객체로 생성하고, Spring Context에 등록된 빈을 Mock 객체로 대체한다.

@Spy, SpyBean

  • @Spy

    • 어노테이션이 붙은 객체를 실제 객체 기반으로 만들지만, 일부 기능만 Stubbing 할 때 사용

  • @SpyBean

    • 해당 객체를 Spy 객체로 생성하고, Spring Context에 등록된 빈을 Spy 객체로 대체한다.

@InjectMocks

@InjectMocks 어노테이션이 붙은 객체가 필요로하는 필드 중 @Mock으로 생성된 객체를 주입해준다.

BDD 스타일로 테스트 코드 배치하기

아래 3개의 테스트가 있다.

@BeforeEach 
void setUp() {
    ❓
} 

@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {
    1-1. 사용자 생성에 필요한 내용 준비
    1-2. 사용자 생성
    1-3. 게시물 생성에 필요한 내용 준비
    1-4. 게시물 생성
    1-5. 댓글 생성에 필요한 내용 준비
    1-6. 댓글 생성

    // given// when// then
    검증
}

@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {
    2-1. 사용자 생성에 필요한 내용 준비
    2-2. 사용자 생성
    2-3. 게시물 생성에 필요한 내용 준비
    2-4. 게시물 생성
    2-5. 댓글 생성에 필요한 내용 준비
    2-6. 댓글 생성
    2-7. 댓글 수정

    // given// when// then
    검증
}

@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {
    3-1. 사용자1 생성에 필요한 내용 준비
    3-2. 사용자1 생성
    3-3. 사용자2 생성에 필요한 내용 준비
    3-4. 사용자2 생성
    3-5. 사용자1의 게시물 생성에 필요한 내용 준비
    3-6. 사용자1의 게시물 생성
    3-7. 사용자1의 댓글 생성에 필요한 내용 준비
    3-8. 사용자1의 댓글 생성
    3-9. 사용자2가 사용자1의 댓글 수정 시도

    // given// when// then
    검증        
}

내용을 살펴보고, 각 항목을 @BeforeEach, given절, when절에 배치한다면 어떻게 배치해야 할까?

(@BeforeEach에 올라간 내용은 공통 항목으로 합칠 수 있습니다. ex. 1-1과 2-1을 하나로 합쳐서 @BeforeEach에 배치)

게시판 게시물에 달리는 댓글을 담당하는 Service Test

댓글을 달기 위해서는 게시물과 사용자가 필요하다.

게시물을 올리기 위해서는 사용자가 필요하다.

직접 코드 배치해보기

@BeforeEach 
void setUp() {
    사용자 생성에 필요한 내용 준비
    사용자 생성
    
    사용자의 게시물 생성에 필요한 내용 준비
    사용자의 게시물 생성
} 

@DisplayName("사용자가 댓글을 작성할 수 있다.")
@Test
void writeComment() {
    // given
    댓글 생성에 필요한 내용 준비

    // when
    댓글 생성

    // then
    검증
}

@DisplayName("사용자가 댓글을 수정할 수 있다.")
@Test
void updateComment() {
    // given
    댓글 생성에 필요한 내용 준비
    댓글 생성

    // when
    댓글 수정

    // then
    검증
}
@DisplayName("자신이 작성한 댓글이 아니면 수정할 수 없다.")
@Test
void cannotUpdateCommentWhenUserIsNotWriter() {
    // given
    사용자2 생성에 필요한 내용 준비
    사용자2 생성
    
    사용자1의 댓글 생성에 필요한 내용 준비
    사용자1의 댓글 생성

    // when
    사용자2가 사용자 1의 댓글 수정 시도

    // then
    검증        
}
  • 공통된 준비 작업을 @BeforeEach로 분리

    • 댓글을 달기 위한 조건 작업

      • 게시물을 작성할 사용자 생성

      • 게시물 생성

  • given

    • 검증하고자 하는 행동에 필요한 작업 수행

      • 댓글 생성과 관련된 내용은 테스트마다 관리할 수 있도록 given 절에 배치

  • when

    • 검증하고자 하는 행동

이번 중간 점검에서 Day 18에 대한 공통 피드백이 있었다.

  • 핵심은 중복 제거가 아닌 도메인

  • 사용자, 게시물은 간접적이므로 setUp()으로, 댓글은 직접적이므로 given절

@BeforeEach로의 분리는 중복 제거가 아니라 도메인이다. 중복 제거로 접근하면 좋지 못한 테스트로 이어질 수 있다.

댓글을 작성해보세요.


채널톡 아이콘