본문 바로가기
프로그래밍/프로젝트

4. TDD 개발 : Read/delete 기능 만들어보기

by 카프카뮈 2021. 2. 5.

 

kafcamus.tistory.com/7

이전 포스팅에 이어서, 오늘은 남은 기능들을 구현해 보는 시간을 가지려구 한다.

 

3. TDD 개발 : Create 기능 만들어보기

최근 프로젝트가 잠시 부진했다. 아무래도 아직은 본격적인 개발보다는 이전 학습 내용을 정리하면서 진행하고 있는지라 일정에 맞춰 기능이 하나씩 나오는 재미가 없어서 그런가보다. 이번주

kafcamus.tistory.com

어차피 도메인 설계도 아직 완성되지 않았고...

현재 만드는 내용은 실제 프로젝트에는 반영되지 않는 부분이다.

지금 진행중인 내용의 목적은, MVC 구조에서 TDD로 어떻게 개발하는지, 어떻게 테스트하는지

이전의 기억을 불러오고 나만의 기준을 정립하기 위해서이다.

 

그래서 오늘 리스트로 값을 전달받는 전체조회 기능, 삭제하는 기능 두 가지를 더 만들어보고

이에 대한 테스트를 추가하는 것까지 연습해보려고 한다.

수정이랑 개별조회 기능은...할려면 하는데 좀 겹치는 것 같아서. 필요한 상황이 닥치면 만들려고 한다.


컨트롤러 테스트 코드에, 저번에 이어 테스트를 하나 추가한다.

@DisplayName("'/books'로 GET 요청 시, 도서의 목록을 반환한다.")
    @Test
    void listTest() throws Exception {
        List<BookResponse> bookResponses = Arrays.asList(
                new BookResponse(도서_ID_1, 도서_이름_1),
                new BookResponse(도서_ID_2, 도서_이름_2)
        );
        when(bookService.list()).thenReturn(bookResponses);

        this.mockMvc.perform(get(API + "/books")
                .accept(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].id").value(도서_ID_1))
                .andExpect(jsonPath("$[1].id").value(도서_ID_2))
                .andExpect(jsonPath("$[0].name").value(도서_이름_1))
                .andExpect(jsonPath("$[1].name").value(도서_이름_2));
    }

이후, 서비스 코드에 이런 메서드를 만들어준다. 

// BookService
public List<BookResponse> list() {
    return null;
}

지금은 컨트롤러에 대한 단위 테스트 및 구현을 진행중이니 내용은 큰 상관이 없다.

저번에는 정말 테스트 만들고 코드 리팩토링하고 다시 테스트 고치고 했는데 이번엔 편의상 좀 빠르게 간다.

바로 컨트롤러 코드를 구현한다. 내용은 다음과 같다.

@GetMapping("/api/books")
public ResponseEntity<List<BookResponse>> list() {
	return ResponseEntity.ok()
			.body(bookService.list());
}

이대로 테스트를 돌리면? 바로 통과된다.

컨트롤러 코드는(특별한 검증 로직이나 페이징 로직 등을 생각하지 않으면) 저것으로 충분하다.

서비스 로직이 전혀 구현되어있지 않지만, Mock으로 통과시켜서 테스트에는 문제가 없었다.

이를 통해 컨트롤러 완성. 바로 서비스로 간다.


서비스 테스트에 다음 메서드를 추가해준다.

@DisplayName("Book 전체 목록 조회 요청 시 올바르게 수행된다.")
    @Test
    void listTest() {
        List<Book> books = Arrays.asList(
                new Book(도서_ID_1, 도서_이름_1),
                new Book(도서_ID_2, 도서_이름_2)
        );
        when(bookRepository.findAll()).thenReturn(books);

        List<BookResponse> foundBooks = bookService.list();

        assertThat(foundBooks)
                .hasSize(2)
                .extracting("name")
                .containsOnly(도서_이름_1, 도서_이름_2);
    }

이를 통과시키기 위해, 실제 기능을 구현해야 한다.

바로 기능을 구현해보자. 앞서 만든 메서드를 다음처럼 고친다.

@Transactional(readOnly = true)
public List<BookResponse> list() {
    return bookRepository.findAll()
            .stream()
            .map(BookResponse::new)
            .collect(Collectors.toList());
}

read 기능은 값의 변경이 없으므로 Transactional이 엄밀히 필요하지는 않다.

하지만 1) 의도치 않게 write가 메서드에서 수행될 경우를 막고, 2) 기능상의 이점을 얻기 위해

readonly 옵션을 사용하게 되었다. 

또한 저장소에서 얻은 Book 클래스의 목록을 stream의 map 기능을 사용하여, DTO로 변환해주고 있다.

나는 MVC 모델을 사용할 때, 컨트롤러와 서비스 간 통신에서도 DTO를 사용하고, 서비스-도메인 레이어에서만 실제 클래스를 사용하는게 맞다고 생각하는 주의이다.

그래서 그런 것이고...이 부분은 검색해보거나 참고 자료를 보다보면 다른 경우도 있었다. 다양한 의견 환영합니다.

 

하여튼, 이대로 테스트를 돌리면 통과되는 것을 확인할 수 있다.

이를 통해, 우리는 목록 조회 기능을 간단히 구현했다.


이제 삭제 기능을 구현해보자.

이번에는 리팩토링 부분을 간단히 건너뛰고, 소스만 쭉 나열해 보겠다.

먼저 컨트롤러 테스트!!

@DisplayName("'/books'로 DELETE 요청 시, 해당 도서를 삭제한다.")
@Test
void deleteTest() throws Exception {
    doNothing().when(bookService).delete(eq(도서_ID_1));

    this.mockMvc.perform(delete(API + "/books/" + 도서_ID_1)).
            andExpect(status().isNoContent());
            
    verify(bookService, times(1)).delete(eq(도서_ID_1));
}

삭제 테스트의 경우, 반환되는 값이 없기 때문에 좀 더 간단하게 테스트를 짤 수 있었다.

특이한 부분이라면 기존 테스트 코드의 when~thenReturn 대신 doNothing이라는 메서드를 사용한 것.

bookService의 delete 메서드가 void형을 반환해서 저렇게 사용했다.

실제 운용에서는, 컨트롤러에서 bookService를 호출할때 아무 동작도 수행하지 않는다.

다만 아예 호출되지 않을 경우는 잘못된 경우이니까, verify로 1차례 호출되었는지 추가 검증한다.

 

이제 컨트롤러 코드이다.

@DeleteMapping("/api/books/{bookId}")
public ResponseEntity<Void> delete(@PathVariable Long bookId) {
    bookService.delete(bookId);
    return ResponseEntity.noContent()
            .build();
}

삭제 부분은 컨트롤러에서 특별하게 해줄 것은 없다.

여기서 참고할 부분이라면 @PathVariable 부분!!

저렇게 써두고 위의 매핑 부분에 이름에 맞는 {}값이 존재한다면, 그 값을 매핑해준다.

만약 위에서 한 목록조회 외에 개별조회(ID를 전달받아 그에 맞는 도서를 반환)를 구현한다면

컨트롤러에서 @PathVariable을 써주면 쉽게 해결할 수 있을 것이다.


서비스 테스트 코드를 보자.

@DisplayName("특정 ID의 도서 삭제 요청 시 해당 도서를 삭제한다")
@Test
void deleteArticleTest() {
    Book book = new Book(도서_ID_1, 도서_이름_1);
    when(bookRepository.findById(eq(도서_ID_1))).thenReturn(Optional.of(book));
    doNothing().when(bookRepository).delete(any(Book.class));
    bookService.delete(도서_ID_1);

    verify(bookRepository, times(1)).delete(any(Book.class));
}

간결하다. findById에 대해 mock 처리를 해주고, delete가 실제로 호출되는지 verify를 해준다.

mock으로 처리하긴 했지만 혹시나 싶어 doNothing도 해줬고.

 

아, 참고로 when/doNothing/thenReturn 등에 들어가는 any는 클래스일때 사용해준다.

물론 그렇기 때문에 any() 라고 써서 모든 곳에 넣을 수 있지만,

더 명확히(다른 타입이 들어오는 경우 테스트가 실패하도록) 하고 싶다면

any 안에 어떤 클래스인지 기재해주고, 숫자일 경우는 eq를 쓴다던가 하는 식으로 상세하게 표기해주면 된다.

이부분에 대해서는 추후 더 자세히 포스팅할 예정이니 이 링크를 참조해 보시길.

 

Mockito Argument Matchers - any(), eq() - JournalDev

Mockito argument matchers, Mockito any(), Mockito method stubbing with flexible arguments, ArgumentMatchers, AdditionalMatchers class, Mockito eq() usage.

www.journaldev.com

그런데, 왜 delete 테스트인데 findById에 대한 mock 처리가 들어갔을까?

서비스 코드를 보자.

public void delete(Long bookId) {
  Book book = bookRepository.findById(bookId)
  		.orElseThrow(() -> new BookNotFoundException(bookId));
  bookRepository.delete(book);
}

이렇게 짜여져 있기 때문이다.

물론 저렇게 하지 않고, deleteById라는 메서드를 사용해도 된다.

하지만, 1) 해당 ID에 해당하는 엔티티가 없을때 예외를 발생시키거나 특정 동작을 취해야 하고, 

2) 연관관계 등이 복잡할 경우 삭제 전에 미리 특정 동작을 취할 필요가 생겨서

저렇게 짰다. 

 

나의 경우 이전 프로젝트에서는 아예 Repository.delete를 쓰지 않고 soft delete로 진행했었는데

그만큼 위험하고 되돌리기 어려우니까... 이에 대해서는 실제 구현 단계에서 많이 고민해보려고 한다.

 

그런데 코드를 보니 이상하다. Exception 중에서 BookNotFoundException이라는 게 있었던가?

당연히 없다. 저 클래스는 내가 RuntimeException을 상속받아 만든 커스텀 예외 클래스이다.

package com.booksdiary.exception;

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long bookId) {
        super(bookId + "에 해당하는 도서를 찾을 수 없습니다.");
    }
}

이 클래스는 Book Entity가 DB에서 검색되지 않을 경우 사용하려고 만든 객체이다.

호출될 경우, 상위 객체의 생성자에 메세지를 넣어 보내준다. 빠른 확인을 위해 id도 함께 받도록 하였고!!


사실 이렇게 Custom exception을 만드는건 호불호가 많이 갈린다.

장점이라면 역시 상황을 파악하기 편하다는 것과 메시지를 통일해서 관리할 수 있다는 것!

단점이라면 클래스가 너무 많아진다는 것. 불필요하게 많이 만들면 협업하기도 힘들고 관리도 안된다.

나는 이번 프로젝트에서 대표적으로 많이 생기는 예외에 대해서만 Custom Exception을 활용하려고 한다.

그 외에는 IllegalArgumentException이라던가...이런걸 쓰는게 맞다고 생각!!

우리 우아한테크코스 크루들이 고민해서 썼던 내용을 함께 포스팅한다. 읽어보시는 독자분들도 고민해보시기를.

woowacourse.github.io/javable/post/2020-08-17-custom-exception/

 

custom exception을 언제 써야 할까?

우아한테크코스의 두 크루인 오렌지와 우가 싸우고 있다. 왜 싸우고 있는지 알아보러 가볼까? 오렌지 : 아니 굳이 사용자 정의 예외 안 써도 됩니다!! 우 : 아닙니다!! 써야 합니다!!! 사용자 정의

woowacourse.github.io


자 이렇게, 목록 조회 기능과 개별 삭제 기능을 구현해 보았다.

만약 당신이 수정이나 개별 조회 기능을 구현해 보고 싶다면,

delete에서 어떻게 @PathVariable로 id를 받아왔는지 참고하고, 

create에서 어떻게 DTO를 @RequestBody로 받아와서 활용했는지 참고하면 된다. 충분히 구현 가능할 것이다!!

 

이제 제대로 작동이 되는지 검증해보자. 이번에도 postman!!

먼저 3개의 Book에 대한 POST 요청을 보내보자. 새로운 책이 생성되었다.

3개를 새로 만들었다.

그 다음에 GET으로 요청을 보내본다. 내가 h2 DB에서 확인한 값이 그대로 반환된다!!

GET으로 요청하니 이렇게 반환값이!

그러면 이제 삭제를 수행해 보자. DELETE로 /api/books/1을 보내본다.

삭제를 요청한다. 204 No Content가 돌아오고, 별도의 반환값은 없다.

다시 GET으로 조회 요청을 보내면 어떨까?

삭제된 값을 제외한 나머지 값이 반환된다.

id 1번이 삭제되어 2,3 두개만 반환된다.


길고 긴 과정을 거쳐, 이제 추가와 조회, 삭제가 가능한 간단한 서버가 완성되었다.

이제 여기서, 우리는 두 가지 의문을 가지게 된다.

 

1. 근데 값 이상한거 보내면 어떻게 돼요? 예외처리는 왜 안하나요?

2. 구현하고 나서 매번 postman으로 테스트해 줘야 하나요? 더 편한 테스트 방법이 없나요?

 

1번은 spring에서 제공하는 validation을 통해 간단하게 해결이 가능하다.

그리고 2번은, 인수 테스트를 통해 획기적으로 개선할 수 있다.

다음 포스팅에서는 이 둘 중 내 맘에 드는걸 먼저 다뤄볼 예정이다.

 

잘못된 내용이 있다면 지적 부탁드립니다.

반응형

댓글