최근 프로젝트가 잠시 부진했다.
아무래도 아직은 본격적인 개발보다는 이전 학습 내용을 정리하면서 진행하고 있는지라
일정에 맞춰 기능이 하나씩 나오는 재미가 없어서 그런가보다.
이번주 주중으로 ERD 설계를 해볼 생각인데, 이에 대해 추후 포스팅해보겠다.
설계가 나온 이후부터는 기능 구현에 대한 포스팅이 주가 될 것으로 보인다.
ERD가 확정되기 전에, 먼저 간단한 도메인을 만들어서 MVC 구조에 대한 틀을 잡아보려고 한다.
간만에 개발하는 만큼, 학습 목적으로 진행하려고 한다.
오늘은 도메인 생성 기능을 TDD로 구현하는 것을 목표로 한다.
다음에는 조회와 삭제에 대한 컨트롤러/서비스 기능 구현,
그리고 validator 구현까지 진행해 보려고 한다.
먼저, 도메인을 작성해 본다.
package com.booksdiary.domain;
import lombok.*;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
일단 도메인에는 ID와 이름만 넣어준다.
아직은 별도의 validator 옵션이 없고, ID는 JPA에서 제공하는 기본키 옵션을 활용했다.
이전 포스팅에서 나온대로, 필요한 생성자들을 만들어 주었다.
그 과정에서 NoArgsConstructor를 함부로 호출할 수 없도록 옵션 추가!
* 최근에 AllArgsContructor가 좋지 않다는 포스팅을 봤다. 내부 필드의 순서가 바뀌면 코드가 엉망이 될 수 있다는 맥락이었는데, 충분히 공감되는 부분. 일단 Builder를 쓰면 괜찮지 않을까 싶지만...그래도 좀 고민되는 부분이다. |
도메인을 간단히 작성했으니, 한번 JPA에서 제공하는 Repository 인터페이스를 만들어보자.
package com.booksdiary.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
이게 끝이다!! 만약 추가적인 기능 구현을 하지 않고 CRUD 기능만 사용하면 정말 간단하다.
이제 TDD 방법론에 따라 테스트 코드부터 개발을 진행해 본다.
TDD는 "테스트가 주도하는 개발"을 의미한다. 과정은 다음과 같다.
- 항상 실패하는 테스트 코드를 작성(Red)
- 테스트가 통과하는 프로덕션 코드 작성(Green)
- 프로덕션 코드를 리팩토링(Blue)
다만 여기서는 불필요한 테스트나 학습 테스트는 건너뛰고, 컨트롤러와 서비스의 테스트 위주로 진행한다.
* 원래대로라면 도메인과 Repository 역시 테스트 코드를 먼저 작성한 뒤 작성하는 것이 좋다. 다만, 현재엔 도메인에 비즈니스 로직이 없고, Repository 역시 기본 기능만 수행하고 있어 비용상 생략할 수 있는 절차로 생각하고 바로 구현했다. |
컨트롤러에 대한 테스트 코드를 구현해보자.
package com.booksdiary.controller;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest
public class BookControllerTest {
private static final String API = "/api";
private static final Long 도서_ID_1 = 1L;
private static final String 도서_이름_1 = "도서_이름_1";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private MockMvc mockMvc;
@BeforeEach
void setUp(WebApplicationContext context) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
}
@DisplayName("'/books'로 POST 요청 시, 도서를 생성한다.")
@Test
void createTest() throws Exception {
BookCreateRequest request = new BookCreateRequest(도서_이름_1);
BookResponse response = new BookResponse(도서_ID_1, 도서_이름_1);
String requestAsString = OBJECT_MAPPER.writeValueAsString(request);
this.mockMvc.perform(post(API + "/books")
.content(requestAsString)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andDo(print());
}
// TODO: 2021/01/27 validation 작동 예시에 대해 테스트코드 작성
}
@SpringBootTest를 통해 테스트를 진행하지 않고, @WebMvcTest를 통해 테스트를 진행했다.
WebMvcTest는 모든 Bean을 불러오지 않고 단위 테스트 개념으로 수행을 하기 때문!!
다만 서비스나 다른 빈들의 동작을 Mock 처리하지 않고 보고 싶다면 @SpringBootTest를 써야한다.
(이를 통합 테스트라고도 한다. 이후 이에 대해 포스팅해 보겠다.)
일단 예외 테스트는 생각하지 않고, 책을 생성하는 요청이 들어올 때, 이를 실제로 생성하는 것에 맞춰 테스트를 작성했다.
그러면 이제, 추가로 코드를 작성해 보자. 먼저 Request와 Response 코드, 즉 DTO 코드이다.
package com.booksdiary.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class BookCreateRequest {
String name;
public Book toBook() {
return Book.builder()
.name(this.name)
.build();
}
}
BookCreateRequest 코드이다. 생성 요청 시에 사용되며, id는 자동생성되므로 이름만 가지고 있다.
Service에서 편리하게 사용할 수 있도록, Books로 변환하여 반환하는 메서드를 하나 가지고 있다.
package com.booksdiary.domain;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class BookResponse {
private Long id;
private String name;
public BookResponse(Books books) {
this.id = books.getId();
this.name = books.getName();
}
}
이번엔 BookResponse 코드이다. Book이 생성될 경우 그 값을 컨트롤러에 돌려주기도 하고,
혹은 Book을 조회할 때 사용할 수도 있다.
Book을 인자로 받아 DTO를 생성하는 특수한 생성자 메서드를 추가했다. 편의상!
이제 DTO를 만들어뒀고, 이를 바탕으로 테스트 코드도 만들어 보았다.
하지만 이 테스트 코드는 당장 돌아가지 않을 것이다. 컨트롤러가 존재하지 않으므로!!
이제 컨트롤러를 만들어보자.
package com.booksdiary.controller;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookResponse;
import com.booksdiary.service.BookService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
@RequiredArgsConstructor
@RestController
public class BookController {
private final BookService bookService;
@PostMapping("/api/books")
public ResponseEntity<BookResponse> create(@RequestBody final BookCreateRequest request) {
final BookResponse response = new BookResponse(도서_ID_1, 도서_이름_1)
return ResponseEntity.created(uri)
.body(response);
}
}
코드를 보면, 기능을 정상적으로 수행하고 있지 않다!
단순하게 테스트에 맞는 값을 반환만 해주고 있다. 왜 이렇게 짠 것일까?
위의 TDD 방법론에서 얘기했듯이, 일단 테스트가 통과하는 코드를 짜야 하니까.
아마 이대로 돌려보면, 테스트가 통과되는 것을 확인할 수 있을 것이다.
이제, 컨트롤러 소스를 정상적으로 리팩토링해보자.
(...)
@RequiredArgsConstructor
@RestController
public class BookController {
private final BookService bookService;
@PostMapping("/api/books")
public ResponseEntity<BookResponse> create(@RequestBody final BookCreateRequest request) {
final BookResponse response = bookService.create(request);
final URI uri = URI.create("/api/books/" + response.getId());
return ResponseEntity.created(uri)
.body(response);
}
}
이제 리팩토링을 통해, 정상적으로 돌아가는 컨트롤러가 완성되었다.
서비스에 DTO를 전달하고, 서비스에서는 이를 처리한 다음 결과값을 다시 DTO로 반환해준다.
이 상태에서, 서비스 클래스를 아래처럼 만들어준다.
package com.booksdiary.service;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookResponse;
import org.springframework.stereotype.Service;
@Service
public class BookService {
public BookResponse create(final BookCreateRequest request) {
return new BookResponse();
}
}
정상적인 기능을 전혀 수행하지 못하는 코드이다. 일단은 이렇게만 만들고, 한번 테스트를 돌려보자. 아마 실패할 것이다.
서비스의 기능이 없으므로, 정상적인 값이 반환되지 않기 때문이다.
그러나 여기서 서비스를 다 짜버리는 것은 컨트롤러 테스트의 영역을 벗어나므로,
대신 컨트롤러 테스트 코드를 한번 수정해준다.
package com.booksdiary.controller;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookResponse;
import com.booksdiary.service.BookService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest
public class BookControllerTest {
private static final String API = "/api";
private static final Long 도서_ID_1 = 1L;
private static final String 도서_이름_1 = "도서_이름_1";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
//추가된 부분 : Mock으로 서비스를 만들어준다.
@MockBean
private BookService bookService;
private MockMvc mockMvc;
@BeforeEach
void setUp(WebApplicationContext context) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
}
@DisplayName("'/books'로 POST 요청 시, 도서를 생성한다.")
@Test
void createTest() throws Exception {
BookCreateRequest request = new BookCreateRequest(도서_이름_1);
BookResponse response = new BookResponse(도서_ID_1, 도서_이름_1);
//추가된 부분 : 서비스에서 기능을 수행할 경우, Mock으로 대체해 원하는 결과가 나오게 조작한다.
when(bookService.create(any(BookCreateRequest.class))).thenReturn(response);
String requestAsString = OBJECT_MAPPER.writeValueAsString(request);
this.mockMvc.perform(post(API + "/books")
.content(requestAsString)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andDo(print());
}
}
추가된 내용은 주석을 달아두었다.
컨트롤러 테스트에서 create라는 서비스의 메서드를 호출하는 로직을 Mock으로 처리해 대체해주므로,
이 코드까지 작성해주면 위의 컨트롤러 테스트는 올바르게 돌아갈 것이다.
이제 컨트롤러 테스트와 컨트롤러 구현 및 리팩토링은 끝이 났다.
참 두서없이 진행됐지만, 결과적으로 우리는 클래스 생성에 대해,
컨트롤러 테스트를 먼저 만들고 이에 맞춰 컨트롤러, 서비스, DTO를 만들어 보았다.
비록 아직 서비스는 엉망이지만, 컨트롤러는 리팩토링까지 거쳐 그럴듯해졌다!
이제 서비스도 마찬가지의 과정을 통해 고쳐보자.
서비스 테스트 코드를 먼저 작성한다.
package com.booksdiary.service;
import com.booksdiary.domain.Book;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookRepository;
import com.booksdiary.domain.BookResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
private static final Long 도서_ID_1 = 1L;
private static final String 도서_이름_1 = "도서_이름_1";
private BookService bookService;
@BeforeEach
void setUp() {
bookService = new BookService(bookRepository);
}
@DisplayName("Books 생성이 올바르게 수행된다.")
@Test
void createTest() {
Book book = new Book(도서_ID_1, 도서_이름_1);
BookCreateRequest request = new BookCreateRequest(도서_이름_1);
BookResponse response = bookService.create(request);
assertThat(response.getName()).isEqualTo(도서_이름_1);
}
}
이번에도 @SpringBootTest 대신 @ExtendWith(MockitoExtension.class)를 쓴게 눈에 띈다.
서비스에 대해 테스트할 때는 모든 Bean을 올릴 필요 없이 내가 필요로 하는 단위만 단위 테스트로 보는 것이므로 이렇게 사용하게 되었다.
같은 맥락으로 컨트롤러에서도 @SpringBootTest 대신 @WebMvcTest를 썼던 것을 기억할 것이다.
이제 이 테스트가 통과되도록 서비스를 고쳐보자.
package com.booksdiary.service;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class BookService {
public BookResponse create(final BookCreateRequest request) {
return new BookResponse(1L, "도서_이름_1");
}
}
우리가 직전에 짠 코드에서 나아진게 거의 없다.
뭐 그래도 테스트는 통과되니까...최소한 클래스가 돌아는 간다는 뜻이라 생각하자.
테스트가 통과되는걸 확인했다면, 이제 본격적으로 리팩토링을 해보자.
package com.booksdiary.service;
import com.booksdiary.domain.Book;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookRepository;
import com.booksdiary.domain.BookResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Service
public class BookService {
private final BookRepository bookRepository;
@Transactional
public BookResponse create(final BookCreateRequest request) {
final Book book = request.toBook();
final Book savedBook = bookRepository.save(book);
return new BookResponse(savedBook);
}
}
일단, @Transactional을 붙여준다. 이를 통해서, 이 메서드가 수행 중인 동안 다른 DB 내의 값 변경을 제어한다.
그리고 받은 request DTO를 통해 Book 클래스를 만들고, 이를 repository를 통해 저장해준다.
Repository의 save 메서드는 저장된 엔티티를 반환해 주기 때문에, savedBook이라는 변수에 저장하였다.
savedBook을 DTO로 변환한 것을 반환하면 동작 종료!!
그러나 이대로 테스트를 돌리면 다시 테스트가 실패할 것이다.
단위 테스트인 만큼 Repository라는 Bean이 호출되는 것을 고려하지 않았기 때문이다.
여기서 서비스 테스트 코드를 수정해준다.
package com.booksdiary.service;
import com.booksdiary.domain.Book;
import com.booksdiary.domain.BookCreateRequest;
import com.booksdiary.domain.BookRepository;
import com.booksdiary.domain.BookResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
private static final Long 도서_ID_1 = 1L;
private static final String 도서_이름_1 = "도서_이름_1";
private BookService bookService;
@Mock
private BookRepository bookRepository;
@BeforeEach
void setUp() {
bookService = new BookService(bookRepository);
}
@DisplayName("Books 생성이 올바르게 수행된다.")
@Test
void createTest() {
Book book = new Book(도서_ID_1, 도서_이름_1);
when(bookRepository.save(any(Book.class))).thenReturn(book);
BookCreateRequest request = new BookCreateRequest(도서_이름_1);
BookResponse response = bookService.create(request);
assertThat(response.getName()).isEqualTo(도서_이름_1);
}
}
앞서와 마찬가지로, @Mock인 Repository를 추가해주고, save 메서드 호출에 대해 when~thenReturn 처리를 해준다.
이렇게 수정한다면, 테스트는 정상적으로 작동할 것이다.
우리는 이를 통해, 서비스와 저장소 역시 완성하였다.
이제 기능이 올바르게 작동하는지 확인해보자.
우리가 서비스와 컨트롤러를 구현했으므로, 이제 적절한 요청 양식을 주소로 보내면,
생성된 Book의 정보가 반환될 것이다.
또한 DB에도 저장될 것이고!!
이에 대해 통합 테스트를 통해 확인해보는 것이 제일 좋은 방법이겠지만,
일단 통합 테스트는 다음 포스트로 미뤄보기로 한다.
대신 Postman과 h2 DB를 통해 직접 눈으로 동작을 확인해보려고 한다.
일단 resource 폴더 내에, application.properties를 추가해준다.
그리고 다음 내용을 넣어준다.
# application.properties
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create
첫 줄부터 보면, 일단 h2 DB의 상태를 확인하는 콘솔을 사용 가능하게 하고,
그 링크와 드라이버, 그리고 유저네임을 설정해준다.
그리고 아래쪽의 내용은 jpa의 sql 포맷을 어떻게 설정할지에 대해, sql 로그를 보여줄지에 대한 옵션과
내가 만든 entity를 바탕으로 jpa가 db를 생성할지에 대한 내용이다.
현재는 별도의 sql 파일이 없으므로, 일단 create로 해뒀다.
* 이 부분은 flyway 적용과 함께 수정할 예정!
일단 지금은 심각하게 받아들이지 말고, 이 내용을 넣은 후 어플리케이션을 실행시켜 보자.
실행시킨 다음, localhost:{port번호}/h2-console로 가본다.
그러면 이렇게 DB 콘솔 접속 페이지가 나온다!! 여기서 아까 적어둔 username 값과 password값을 입력하고 접속하자!!
(내가 위에 적어둔 properties에는 username : sa, password는 없는 것으로 되어있다. 저 화면이 뜨면, 바로 로그인을 눌러 접속가능하다.)
이제 DB 접속까지 끝냈으니, 콘솔을 한번 보자.
아마 Book이라는 테이블이 왼쪽에 존재하지만, 눌러서 SQL을 작성하여 내용을 확인해도 아무 내용이 없을 것이다.
초기값을 따로 설정해 주지 않아서, DB가 비어있기 때문이다.
여기서, Postman을 통해 요청을 보내보자.
포스트맨은 REST API 테스트를 아주 편리하게 만들어주는 도구이다.
내가 원하는 주소에 원하는 내용을 넣어 요청을 보내고, 답변으로 오는 값을 확인할 수 있다.
이제 포스트맨을 통해 localhost에 요청을 보내보자.
이름을 JSON으로 넣고, localhost:8080/api/books로 POST 요청을 보냈다.
이때 우리가 보낸 JSON 값은, @RequestBody 어노테이션을 통해 컨트롤러에서 BookCreateRequest로 변환된다.
이 값이 전달되어 서비스에서는 BookRepository에 생성 요청을 보내고, 그렇게 생성된 값이 BookResponse에 실려 반환된 것이다.
한번 요청을 더 보내보자.
두번째 책을 생성했다. id가 2인 것을 확인할 수 있는데, 앞서 생성된 책은 1이었고 id는 auto_increment로 생성되니까!!
그럼 이제 이 값들이 DB에 있는지, H2-Console로 확인해 보자.
다행히 값이 존재하는 걸 확인할 수 있었다.
복잡한 여러 과정을 거쳐, 우리는 다음을 구현하였다.
- Book 도메인과 저장소, DTO 클래스
- Book에 대한 요청을 받아 처리하는 컨트롤러
- Book에 대한 요청 정보를 컨트롤러로부터 받아 트랜잭션과 순서 처리를 담당하는 서비스
- 컨트롤러와 서비스의 정상 동작을 테스트하는 테스트 코드
다만, 아직 우리는 이 부분이 마음에 걸린다.
- 예외처리도, 예외 테스트도 존재하지 않음
- 생성 기능만 존재하고 삭제, 조회, 수정 등이 불가함
다음 포스트에서는, 조회와 삭제를 구현한 뒤 validator를 적용하는 것을 목표로 한다.
잘못된 내용이나 이해되지 않는 내용이 있다면 지적 부탁드립니다.
'프로그래밍 > 프로젝트' 카테고리의 다른 글
6. 프로젝트 요구사항 정리와 ERD (0) | 2021.02.18 |
---|---|
5. Request DTO의 Validation / 예외 테스트와 ParameterizedTest (0) | 2021.02.09 |
4. TDD 개발 : Read/delete 기능 만들어보기 (0) | 2021.02.05 |
2. Lombok 사용과 고민 (1) | 2021.01.31 |
1. 컨벤션과 Git Flow (0) | 2021.01.26 |
댓글