[회고] 코드숨 스프링 14기 3주차

gwk·2023년 2월 4일
0

코드숨 스프링

목록 보기
3/3

과제 - Spring Web으로 Todo REST API 만들기

지난주 과제에서 추가된 요구 사항은 단위 테스트 및 컨트롤러들의 웹 레이어 슬라이스 테스트이다. 또한 JaCoCo 리포트 작성을 위한 설정이 build.gradle에 포함되어 있는데 테스트 커버리지 100%를 달성하는 미션이 주어졌다. 수행한 미션 소스코드 는 링크에 있다. 미션을 수행하면서 왜 테스트 코드 작성이 필요하고 이를 스프링에서 어떻게 지원하고 있는지 찾아보았는데 Practical Test PyramidTest Driven Development with Spring Boot 도움이 많이 되었다.

테스트

테스트 피라미드

그의 글을 읽어 보니 마틴 파울러는 에자일 방법론의 실천 방법 중 익스트림 프로그래밍을 가장 선호하는 것 같다. 익스트림 프로그래밍에서는 TDD를 하지만 마틴 파울러의 경우 그 과정보다는 TDD의 산출물인 자동화된 테스트 코드(self-testing code) 작성에 더 중점을 둔다. 그 이유는 안정적인 리팩토링과 지속적 통합(continuous integration)과 지속적 전달(continuous delivery)을 위해 자동화된 테스트는 없어서는 안되기 때문이다.

자동화된 코드 작성과 자주 연관되는 개념으로 테스트 피라미드를 들 수 있다. 테스트 피라미드는 개발자가 작성해야 하는 자동화 테스트의 종류와 분량을 도식화한 것인데 이를 스프링 부트에 특화하면 다음과 같다.

위로 갈 수록 dependencies가 많아지고 시간적 비용이 더 들기 때문에 작성하는 테스트의 양이 줄어든다. 다른 한 편으로는 이런 다이어그램 형태에 너무 신경을 쓸 것이 아니라 좀더 본질적으로 테스트 작성 목적을 염두할 것을 권장하기도 한다.

한편으로는 테스트 분류와 정의에 대한 모호함에서 때문에 피라미드든 서버리스 진영에서 이야기하는 육각형이든 테스트 분포의 형태 보다는 테스트를 작성하는 근본적인 목적인

Write expressive tests that establish clear boundaries, run quickly & reliably, and only fail for useful reasons.

에 치중할 것을 권장한다.

우선 금주에는 통합 테스트나 API 연동 컨트렉트 테스트, E2E 테스트는 과제에 포함되지 않았으니 다음 기회에 알아보도록 하고 단위 테스트와 스프링 컨텍스의 일부분만을 사용하는 slice test 중 컨트롤러만 테스트한다.

단위 테스트

단위 테스트가 모든 테스트의 근간이 된다. 단위 테스트는 테스트하는 객체의 기능 단위가 제대로 동작함을 확인한다. 단위 테스트는 가장 작은 스코프를 가지며 모든 테스트 중 가장 많은 수를 작성하게 된다. 단위 테스트에서 이야기하는 단위는 OOP를 사용한다면 하나의 메서드에서 클래스 전체가 될 수 있다. 단위 테스트 작성 시 일반적으로 외부 협력하는 객체들을 테스트 더블로 교체한다.

단위 테스트의 형식은 "Arrange", "Act", "Assert"("given", "when", "then")를 따르는 것이 좋고 다음은 단위 테스트 예제를 제시하기 위한 ExampleController 클래스이다.

@RestController
public class ExampleController {

  private final PersonRepository personRepository;

  public ExampleController(PersonRepository personRepository) {
    this.personRepository = personRepository;
  }

  @GetMapping("/hello/{lastName}")
  public String hello(@PathVariable String lastName) {
    Optional<Person> foundPerson = personRepository.findByLastName(lastName);

    return foundPerson
        .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName()))
        .orElse(String.format("Who is this '%s' you are talking about?", lastName));
  }
}

hello(lastName) 메서드를 테스트하기 위한 단위 테스트는 다음과 같다.

@ExtendWith(MockitoExtension.class)
class ExampleControllerTest {

  @Mock
  private PersonRepository personRepository;

  @InjectMocks
  private ExampleController subject;
	
  @Test
  void shouldReturnFullNameOfAPerson() {
    Person peter = new Person("Peter", "Pan");
    given(personRepository.findByLastName("Pan")).willReturn(Optional.of(peter));

    String greeting = subject.hello("Pan");

    assertThat(greeting).isEqualTo("Hello Peter Pan!");
  }

  @Test
  void shouldTellIfPersonIsUnknown() {
    given(personRepository.findByLastName(anyString())).willReturn(Optional.empty());

    String greeting = subject.hello("Pan");

    assertThat(greeting).isEqualTo("Who is this 'Pan' you are talking about?");
  }
}

단위 테스트를 JUnit을 사용하여 작성하였고, Mockito를 사용하여 PersonRepository 객체를 stub으로 교체했다.

슬라이스 테스트

위의 예제에서 간단한 컨트롤러의 단위 테스트를 제시했는데, 스프링의 컨트롤러들의 테스할 때 이 방법은 한 가지 단점이 있다: 스프링 MVC 컨트롤러의 경우 에노테이션을 통해 매핑 경로, HTTP 동사, 입력에 대한 유효성, 경로 및 쿼리 파라미터 파싱들을 선언하기에 단순히 컨트롤러의 매서드를 호출하는 단위 테스트로는 에노테이션에 녹아 있는 중요한 기능들을 테스트하기 힘들다. 스프링에서 이런 문제를 해결하기 위해서 MockMVC를 활용한 테스트를 제공하는데 @WebMvcTest 에노테이션을 사용하면 웹 레이어에 해당하는 빈들과 DispatchServlet을 제공하는 스프링 컨텍스트를 띄울 수 있다.

@WebMvcTest(ExampleController.class)
class ExampleControllerAPITest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private PersonRepository personRepository;

  @Test
  void shouldReturnFullName() throws Exception {
    Person peter = new Person("Peter", "Pan");
    given(personRepository.findByLastName("Pan")).willReturn(Optional.of(peter));

    mockMvc.perform(get("/hello/Pan"))
        .andExpect(content().string("Hello Peter Pan!"))
        .andExpect(status().is2xxSuccessful());
  }
}

Spring Boot Starter Test

Spring Boot Starter Test는 Spring Initializr를 통해 생성되는 모든 스프링 부트 프로젝트에 포함되는 의존성인데 이를 포함함으로서 단위 테스트에 필요한 JUnit, Mockito, Assertion 리이브러리인 AssertJ, Hamcrest, JsonPath, JSONassert 등의 의존성을 포함한다. 통합 테스트나 E2E 테스트를 진행하기 위해서 WireMock, Testcontainers, Selenium과 같은 라이브러리가 추가적으로 필요할 수 있다.

Sping-boot-start-test에 의해 설치되는 모든 의존성을 보기 위해서 ./mvnw dependency:tree를 하면

과 같은 dependency가 설치됨을 확인할 수 있고 그레이들을 사용할 경우 ./gradlew dependencies을 통해
dependencies를 확인할 수 있다.

개인적을 헷갈렸던 점이 Spring Boot 2.4부터는 JUnit4를 사용하기 위한 JUnit Vintage engine이 스타터에서 빠져있기에 다음과 같이 궂이 JUnit5만 사용하겠다고 의존성을 명시하지 않아도 자동으로 된다.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
       <groupId>org.junit.vintage</groupId>
       <artifactId>junit-vintage-engine</artifactId>
     </exclusion>
  </exclusions>
</dependency>
testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

Spring Boot > 2.4에서 JUnit4를 사용해야할 경우 다음과 같은 의존성을 추가하면 된다.

<dependency>
  <groupId>org.junit.vintage</groupId>
  <artifactId>junit-vintage-engine</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>

코드 리뷰

  • IntelliJ IDEA 세팅
  • 변화에 대응할 수 있도록 flaky test를 작성하지 않도록 주의하자.
  • 테스트 명칭은 DAMP (Descriptive And Meaningful Phrases)로 간결함보다 가독성을 생각해 보자.
  • 테스트 커버리지에 집중하다 보니 도메인 객체의 getter/setter 테스트도 작성했다. 사실 이것은 과한 것 같고 도메인 객체를 어떻게 사용해야 할지 좀 더 기능 중심적인 테스트 작성을 지향해야 할 것 같다.
    - 같은 맥락에서 단위 테스트와 @WebMvcTest slice test 중복되는 테스트도 작성했는데 이 부분을 간소화할 수 있을 것 같다.
  • 계층형 테스트 작성

백로그

참조

profile
백엔드 개발자

0개의 댓글