TDD란?
TDD는 테스트 주도 개발을 의미합니다.
개발자가 특정 기능에 대한 테스트를 작성한 다음,
그 테스트를 통과하기 위한 최소한의 코드를 작성하고,
리팩토링하는 과정으로 진행됩니다.
과정 Red -> Green -> Refactor
각 과정을 Red, Green, Refactor 라고 합니다.
Red
특정 기능에 해당하는 테스트를 작성합니다.
(구현되지 않았기 때문에 실패합니다)
Green
테스트를 통과하기 위한 최소한의 코드를 작성합니다.
(효율보다 통과를 우선시합니다)
Refactor
테스트가 통과하는 것을 확인하면서 코드를 리팩토링합니다.
데모 프로젝트
docker container 프로젝트 TDD용 프로젝트를 생성하겠습니다.
ControllerTest 클래스를 생성합니다.
@SpringBootTest 를 사용하지 않고 @WebMvcTest를 사용하는 것으로,
테스트 시 스프링 애플리케이션이 전부 가동되지 않고 필요한 부분만 로드되도록 합니다.
@WebMvcTest(PostController.class)
@AutoConfigureMockMvc
public class PostControllerTest {
@Autowired
MockMvc mockMvc;
}
PostController클래스를 사용하여 테스트하도록 설정하지만,
PostController클래스가 존재하지 않기에 오류가 발생합니다.
(PostController를 생성합니다)
@AutoConfigureMockMvc 를 추가하여 서블릿 컨테이너 없이 HTTP 요청/응답에 대한 테스트를 진행할 수 있습니다.
(더 빠르고 효율적으로 테스트할 수 있습니다)
@Autowired로 MockMvc를 주입하는 것을 통해,
@AutoConfigureMockMvc로 설정된 MockMvc를 사용합니다.
초기화
테스트에 사용할 Post 객체를 초기화합니다.
(@BeforeEach 어노테이션을 통해 테스트가 실행되기 전에 초기화하도록 합니다)
List<Post> posts = new ArrayList<>();
@BeforeEach
void setUp() {
posts = List.of(
new Post(1, 1, "Hello, World", "This is first post.", null),
new Post(2, 1, "Hello, Spring", "This is second post.", null)
);
}
package com.ride.posts.post;
public record Post(
Integer id,
Integer userId,
String title,
String body,
Integer version) {
}
테스트코드
모든 post를 반환하는 test 코드를 작성합니다.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
//
@Test
void shouldFindAllPosts() throws Exception {
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk());
}
마찬가지로 세부 로직을 구현하지 않았기 때문에 오류가 발생합니다.
MockHttpServletResponse:
Status = 404
Error message = No static resource api/posts.
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
Status
Expected :200
Actual :404
200(status().isOk())을 기대했지만 404를 반환했습니다.
이제 controller의 코드를 작성합니다.
package com.ride.posts.post;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/posts")
public class PostController {
@GetMapping("")
List<Post> findAll() {
return null;
}
}
null이 아닌 객체를 반환하도록 합니다.
Repository를 작성합니다.
package com.ride.posts.post;
import org.springframework.data.repository.ListCrudRepository;
public interface PostRepository extends ListCrudRepository<Post, Integer> {
}
Post를 수정합니다.
package com.ride.posts.post;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
public record Post(
@Id
Integer id,
Integer userId,
String title,
String body,
@Version
Integer version) {
}
docker-compose에 DB container 정보를 입력합니다.
services:
postgres:
image: 'postgres:16.0'
environment:
- 'POSTGRES_DB=posts'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=ride'
ports:
- '5432:5432'
(위 스크립트를 기반으로 docker container를 생성 - 가동합니다)
스키마를 정의합니다.
schema.sql
CREATE TABLE IF NOT EXISTS Post (
id INT NOT NULL,
user_id INT NOT NULL,
title VARCHAR(250) NOT NULL,
body TEXT NOT NULL,
version INT,
PRIMARY KEY (id)
);
애플리케이션 가동 시 sql스크립트가 실행되도록 application.properties에 아래 스크립트를 작성합니다.
spring.sql.init.mode=always
데이터 로직을 추가로 작성합니다.
(DB에 데이터가 없으면, resources의 data/posts.json 파일을 삽입하도록 합니다)
package com.ride.posts.post;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
@Component
public class PostDataLoader implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(PostDataLoader.class);
private final ObjectMapper objectMapper;
private final PostRepository postRepository;
public PostDataLoader(ObjectMapper objectMapper, PostRepository postRepository) {
this.objectMapper = objectMapper;
this.postRepository = postRepository;
}
@Override
public void run(String... args) throws Exception {
if (postRepository.count() == 0) {
String POST_JSON = "/data/posts.json";
logger.info("Loading posts into database from JSON: {}", POST_JSON);
try (InputStream inputStream = TypeReference.class.getResourceAsStream(POST_JSON)) {
Posts response = objectMapper.readValue(inputStream, Posts.class);
postRepository.saveAll(response.posts());
} catch (IOException e) {
throw new RuntimeException("Failed to read JSON data", e);
}
}
}
}
애플리케이션 실행시 도커 컨테이너가 생성 - 가동됩니다.
controller가 repository로부터 값을 받아오도록 변환합니다.
package com.ride.posts.post;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostRepository postRepository;
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@GetMapping("")
List<Post> findAll() {
return postRepository.findAll();
}
}
테스트 클래스에서 Repository를 사용할 수 있도록 의존성을 주입합니다.
또한, 테스트 클래스에서 초기화한 값을 비교하도록 합니다.
package com.ride.posts.post;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.ArrayList;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PostController.class)
@AutoConfigureMockMvc
public class PostControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
PostRepository postRepository;
List<Post> posts = new ArrayList<>();
@BeforeEach
void setUp() {
posts = List.of(
new Post(1, 1, "Hello, World", "This is first post.", null),
new Post(2, 1, "Hello, Spring", "This is second post.", null)
);
}
@Test
void post의_findById메서드_동작_및_유효한id_데이터_검증() throws Exception {
String jsonResponse = """
[
{
"id":1,
"userId":1,
"title":"Hello, World",
"body":"This is first post.",
"version":null
},
{
"id":2,
"userId":1,
"title":"Hello, Spring",
"body":"This is second post.",
"version":null
}
]
""";
when(postRepository.findAll()).thenReturn(posts);
// findAll 호출 시, 위에서 생성한 posts 객체를 반환하도록
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
}
어떤 테스트인지 알아보기 쉽도록 한글로 작성했습니다.
이제 post id를 기반으로 값을 반환하는 findById 메서드에 대한 테스트 코드를 작성합니다.
@Test
void post의_findById메서드_동작_및_유효한id_데이터_검증() throws Exception {
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isOk());
}
마찬가지로 findById 호출 시, 위에서 생성한 posts 객체의 요소를 반환하도록 합니다.
api를 정의하고 테스트합니다.
다음으로 해당 api에 대한 코드를 contoller에 작성합니다.
@GetMapping("/{id}")
Optional<Post> findById(@PathVariable Integer id) {
return postRepository.findById(id);
}
이제 다시 findById로 받아온 값을 검증하도록,
테스트 코드를 작성합니다.
import static java.lang.StringTemplate.STR;
//
@Test
void post의_findById메서드_동작_및_유효한id_데이터_검증() throws Exception {
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
var post = posts.get(0);
String jsonResponse = String.format("""
{
"id":%d,
"userId":%d,
"title":"%s",
"body":"%s",
"version":null
}
""", post.id(), post.userId(), post.title(), post.body());
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
이제 유효하지 않은 id에 대한 테스트 코드를 작성합니다.
@Test
void post의_findById메서드_동작_및_유효하지_않은id_데이터_검증() throws Exception {
when(postRepository.findById(999)).thenThrow(PostNotFoundException.class);
mockMvc.perform(get("/api/posts/999"))
.andExpect(status().isNotFound());
}
Controller, PostNotFountException 클래스 코드를 작성합니다.
@GetMapping("/{id}")
Optional<Post> findById(@PathVariable Integer id) {
return Optional.ofNullable(postRepository.findById(id)
.orElseThrow(PostNotFoundException::new));
}
package com.ride.posts.post;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class PostNotFoundException extends RuntimeException {
}
이제 post를 생성하는 코드를 작성합니다.
@Test
void post의_create메서드_동작_및_유효한id_데이터_삽입() throws Exception {
mockMvc.perform(post("/api/posts")
.contentType("application/json")
.content(""))
.andExpect(status().isCreated());
}
Status
Expected :201
Actual :405
controller에 post api를 작성합니다.
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("")
Post create(@RequestBody @Validated Post post) {
return postRepository.save(post);
}
Status
Expected :201
Actual :400
이제 null이 아닌, 값을 담아서 테스트합니다.
@Test
void post의_create메서드_동작_및_유효한id_데이터_삽입() throws Exception {
var post = new Post(
3,
1,
"New Title",
"New Body",
null
);
when(postRepository.save(post)).thenReturn(post);
String jsonResponse = String.format("""
{
"id":%d,
"userId":%d,
"title":"%s",
"body":"%s",
"version":null
}
""", post.id(), post.userId(), post.title(), post.body());
mockMvc.perform(post("/api/posts")
.contentType("application/json")
.content(jsonResponse))
.andExpect(status().isCreated());
}
유효하지 않은 id에 대해서도 코드를 작성합니다.
build.gradle에 validation 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
PostEntity에 Validation 어노테이션을 추가합니다.
package com.ride.posts.post;
import jakarta.validation.constraints.NotEmpty;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
public record Post(
@Id
Integer id,
Integer userId,
@NotEmpty
String title,
@NotEmpty
String body,
@Version
Integer version) {
}
@Test
void post의_create메서드_동작_및_유효하지_않은_데이터_삽입() throws Exception {
var post = new Post(
2,
1,
"",
"",
null
);
when(postRepository.save(post)).thenReturn(post);
String jsonResponse = String.format("""
{
"id":%d,
"userId":%d,
"title":"%s",
"body":"%s",
"version":null
}
""", post.id(), post.userId(), post.title(), post.body());
mockMvc.perform(post("/api/posts")
.contentType("application/json")
.content(jsonResponse))
.andExpect(status().isBadRequest());
}
Title, Body를 빈 값으로 보냅니다.
update에 대한 코드도 작성합니다.
@Test
void post의_update메서드_동작_및_유효한_데이터_변경() throws Exception {
var updated = new Post(
1,
1,
"This is a new title",
"This is a new body",
1
);
when(postRepository.save(updated)).thenReturn(updated);
mockMvc.perform(put("/api/posts/1")
.contentType("application/json")
.content(""))
.andExpect(status().isOk());
}
Status
Expected :200
Actual :405
405를 확인했으니 controller에 put api를 작성합니다.
@PutMapping("/{id}")
Post update(@PathVariable Integer id, @RequestBody Post post) {
Optional<Post> existing = postRepository.findById(id);
if(existing.isPresent()) {
Post updated = new Post(
existing.get().id(),
existing.get().userId(),
post.title(),
post.body(),
existing.get().version()
);
return postRepository.save(updated);
} else {
throw new PostNotFoundException();
}
}
테스트 코드도 보완합니다.
@Test
void post의_update메서드_동작_및_유효한_데이터_변경() throws Exception {
var updated = new Post(
1,
1,
"This is a new title",
"This is a new body",
1
);
when(postRepository.findById(1)).thenReturn(Optional.of(updated));
when(postRepository.save(updated)).thenReturn(updated);
String requestBody = String.format("""
{
"id":%d,
"userId":%d,
"title":"%s",
"body":"%s",
"version":null
}
""", updated.id(), updated.userId(), updated.title(), updated.body());
mockMvc.perform(put("/api/posts/1")
.contentType("application/json")
.content(requestBody))
.andExpect(status().isOk());
}
유효하지 않은 경우에 대한 테스트코드를 작성합니다.
Controller에 @Valid를 추가합니다.
@PutMapping("/{id}")
Post update(@PathVariable Integer id, @RequestBody @Valid Post post) {
테스트 클래스의 메서드 명, 검증하고자 하는 값, 상태 코드에 대한 부분을 수정하여 추가합니다.
@Test
void post의_update메서드_동작_및_유효하지_않은_데이터_변경() throws Exception {
var updated = new Post(
1,
1,
"",
"",
1
);
when(postRepository.findById(1)).thenReturn(Optional.of(updated));
when(postRepository.save(updated)).thenReturn(updated);
String requestBody = String.format("""
{
"id":%d,
"userId":%d,
"title":"%s",
"body":"%s",
"version":null
}
""", updated.id(), updated.userId(), updated.title(), updated.body());
mockMvc.perform(put("/api/posts/1")
.contentType("application/json")
.content(requestBody))
.andExpect(status().isBadRequest());
}
마지막으로 데이터를 삭제하는 코드를 작성합니다.
@Test
void post의_delete메서드_동작_및_유효한id_데이터_삭제() throws Exception {
doNothing().when(postRepository).deleteById(1);
mockMvc.perform(delete("/api/posts/1"))
.andExpect(status().isNoContent());
verify(postRepository, times(1)).deleteById(1);
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/{id}")
void delete(@PathVariable Integer id) {
postRepository.deleteById(id);
}
@Test
void post의_delete메서드_동작_및_유효하지_않은id_데이터_삭제() throws Exception {
doThrow(new PostNotFoundException()).when(postRepository).deleteById(999);
mockMvc.perform(delete("/api/posts/999"))
.andExpect(status().isNotFound());
verify(postRepository, times(1)).deleteById(999);
}
이제 리팩토링을 진행합니다.
MediaType을 상수로 만들었습니다.
ObjectMapper를 사용하여 JSON객체 생성을 유연하게 하도록 만들었습니다.
package com.ride.posts.post;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PostController.class)
@AutoConfigureMockMvc
public class PostControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
PostRepository postRepository;
List<Post> posts;
ObjectMapper objectMapper;
@BeforeEach
void setUp() {
posts = List.of(
new Post(1, 1, "Hello, World", "This is first post.", null),
new Post(2, 1, "Hello, Spring", "This is second post.", null)
);
objectMapper = new ObjectMapper();
}
@Test
void post의_findAll메서드_동작_및_데이터_검증() throws Exception {
String jsonResponse = objectMapper.writeValueAsString(posts);
when(postRepository.findAll()).thenReturn(posts);
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
@Test
void post의_findById메서드_동작_및_유효한id_데이터_검증() throws Exception {
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
String jsonResponse = objectMapper.writeValueAsString(posts.get(0));
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
@Test
void post의_findById메서드_동작_및_유효하지_않은id_데이터_검증() throws Exception {
when(postRepository.findById(999)).thenThrow(new PostNotFoundException());
mockMvc.perform(get("/api/posts/999"))
.andExpect(status().isNotFound());
}
@Test
void post의_create메서드_동작_및_유효한id_데이터_삽입() throws Exception {
var post = new Post(3, 1, "New Title", "New Body", null);
when(postRepository.save(any(Post.class))).thenReturn(post);
String jsonRequest = objectMapper.writeValueAsString(post);
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isCreated())
.andExpect(content().json(jsonRequest));
}
@Test
void post의_create메서드_동작_및_유효하지_않은_데이터_삽입() throws Exception {
var post = new Post(2, 1, "", "", null);
String jsonRequest = objectMapper.writeValueAsString(post);
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isBadRequest());
}
@Test
void post의_update메서드_동작_및_유효한_데이터_변경() throws Exception {
var updated = new Post(1, 1, "This is a new title", "This is a new body", 1);
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
when(postRepository.save(any(Post.class))).thenReturn(updated);
String jsonRequest = objectMapper.writeValueAsString(updated);
mockMvc.perform(put("/api/posts/1")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isOk())
.andExpect(content().json(jsonRequest));
}
@Test
void post의_update메서드_동작_및_유효하지_않은_데이터_변경() throws Exception {
var updated = new Post(1, 1, "", "", 1);
String jsonRequest = objectMapper.writeValueAsString(updated);
mockMvc.perform(put("/api/posts/1")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isBadRequest());
}
@Test
void post의_delete메서드_동작_및_유효한id_데이터_삭제() throws Exception {
doNothing().when(postRepository).deleteById(1);
mockMvc.perform(delete("/api/posts/1"))
.andExpect(status().isNoContent());
verify(postRepository, times(1)).deleteById(1);
}
@Test
void post의_delete메서드_동작_및_유효하지_않은id_데이터_삭제() throws Exception {
doThrow(new PostNotFoundException()).when(postRepository).deleteById(999);
mockMvc.perform(delete("/api/posts/999"))
.andExpect(status().isNotFound());
verify(postRepository, times(1)).deleteById(999);
}
}
이제 Repository에 대한 테스트 코드를 작성합니다.
연결 설정이 잘 되었는지 확인합니다.
package com.ride.posts.post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@Testcontainers
@DataJdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class PostRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.0");
@Autowired
PostRepository postRepository;
@Test
void 연결설정확인() {
assertThat(postgres.isCreated()).isTrue();
assertThat(postgres.isRunning()).isTrue();
}
}
Title 내용으로 post를 불러오는 코드를 작성합니다.
@Test
void Title로_Post찾기() {
Post post = postRepository.findByTitle("Hello, World");
assertThat(post).isNotNull();
}
초기화용 데이터도 생성합니다.
@BeforeEach
void setUp() {
List<Post> posts = List.of(new Post(
1,
1,
"Hello, World",
"This is my first post",
null
));
postRepository.saveAll(posts);
}
repository에 로직을 작성합니다.
package com.ride.posts.post;
import org.springframework.data.repository.ListCrudRepository;
public interface PostRepository extends ListCrudRepository<Post, Integer> {
Post findByTitle(String title);
}
통합 테스트를 위한 컨트롤러 테스트 클래스를 생성합니다.
기존에 ControllerTest에 작성했던 것과의 차이는 아래와 같습니다.
1. TestTemplate 객체를 생성하여 controller와 HTTP로 통신하고, DB로(container)부터 값을 받아옵니다.
2. @Rollback을 사용하여 테스트 결과가 DB에 영향을 미치지 않도록합니다
package com.ride.posts.post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.annotation.Rollback;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Rollback
public class PostControllerIntTest {
private static final String BASE_URL = "/api/posts";
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.0");
@Autowired
TestRestTemplate restTemplate;
@Test
void 연결설정확인() {
assertThat(postgres.isCreated()).isTrue();
assertThat(postgres.isRunning()).isTrue();
}
@Test
void 모든_Post_불러오기() {
Post[] posts = restTemplate.getForObject(BASE_URL, Post[].class);
assertThat(posts.length).isGreaterThanOrEqualTo(100);
}
@Test
void 유효한_id로_Post_불러오기() {
Integer id = 1;
ResponseEntity<Post> response = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.GET, null, Post.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
void 유효하지_않은_id로_Post_불러오기() {
Integer id = 999;
ResponseEntity<Post> response = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.GET, null, Post.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@Rollback
void 유효한_객체로_Post_생성하기() {
Post post = new Post(101, 1, "101 Title", "101 Body", null);
ResponseEntity<Post> response = restTemplate.exchange(BASE_URL, HttpMethod.POST, new HttpEntity<Post>(post), Post.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(Objects.requireNonNull(response.getBody()).id()).isEqualTo(post.id());
assertThat(response.getBody().userId()).isEqualTo(post.userId());
assertThat(response.getBody().title()).isEqualTo(post.title());
assertThat(response.getBody().body()).isEqualTo(post.body());
}
@Test
@Rollback
void 유효하지_않은_객체로_Post_생성하기() {
Post post = new Post(101, 1, "", "", null);
ResponseEntity<Post> response = restTemplate.postForEntity(BASE_URL, post, Post.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@Rollback
void 유효한_객체로_Post_업데이트하기() {
Integer id = 99;
Post existing = restTemplate.getForObject(BASE_URL + "/" + id, Post.class);
assertThat(existing).isNotNull();
Post updated = new Post(existing.id(), existing.userId(), "New Post title", "New Post body", existing.version());
ResponseEntity<Post> responsePut = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.PUT, new HttpEntity<>(updated), Post.class);
assertThat(responsePut.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(responsePut.getBody()).isNotNull();
assertThat(responsePut.getBody().id()).isEqualTo(updated.id());
assertThat(responsePut.getBody().userId()).isEqualTo(updated.userId());
assertThat(responsePut.getBody().title()).isEqualTo(updated.title());
assertThat(responsePut.getBody().body()).isEqualTo(updated.body());
}
@Test
@Rollback
void 유효하지_않은_객체로_Post_업데이트하기() {
Integer id = 1;
Post updated = new Post(1, 1, "", "", null);
ResponseEntity<Post> response = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.PUT, new HttpEntity<>(updated), Post.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@Rollback
void 유효한_id로_Post_삭제하기() {
Integer id = 2;
ResponseEntity<Void> response = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.DELETE, null, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
@Rollback
void 유효하지_않은_id로_Post_삭제하기() {
Integer id = 999;
ResponseEntity<Void> response = restTemplate.exchange(BASE_URL + "/" + id, HttpMethod.DELETE, null, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
이에 따라 Controller도 보완해줍니다.
package com.ride.posts.post;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostRepository postRepository;
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@GetMapping("")
List<Post> findAll() {
return postRepository.findAll();
}
@GetMapping("/{id}")
Optional<Post> findById(@PathVariable Integer id) {
return Optional.ofNullable(postRepository.findById(id)
.orElseThrow(PostNotFoundException::new));
}
@ResponseStatus(HttpStatus.CREATED)
@PostMapping("")
Post create(@RequestBody @Validated Post post) {
return postRepository.save(post);
}
@PutMapping("/{id}")
Post update(@PathVariable Integer id, @RequestBody @Valid Post post) {
Optional<Post> existing = postRepository.findById(id);
if(existing.isPresent()) {
Post updated = new Post(
existing.get().id(),
existing.get().userId(),
post.title(),
post.body(),
existing.get().version()
);
return postRepository.save(updated);
} else {
throw new PostNotFoundException();
}
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable Integer id) {
if (postRepository.existsById(id)) {
postRepository.deleteById(id);
return ResponseEntity.noContent().build();
} else {
return ResponseEntity.notFound().build();
}
}
}
기존에 작성했던 테스트 또한 보완해줍니다.
package com.ride.posts.post;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
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 java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PostController.class)
@AutoConfigureMockMvc
public class PostControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
PostRepository postRepository;
List<Post> posts;
ObjectMapper objectMapper;
@BeforeEach
void setUp() {
posts = List.of(
new Post(1, 1, "Hello, World", "This is first post.", null),
new Post(2, 1, "Hello, Spring", "This is second post.", null)
);
objectMapper = new ObjectMapper();
}
@Test
void 모든_Post_불러오기() throws Exception {
String jsonResponse = objectMapper.writeValueAsString(posts);
when(postRepository.findAll()).thenReturn(posts);
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
@Test
void 유효한_id로_Post_불러오기() throws Exception {
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
String jsonResponse = objectMapper.writeValueAsString(posts.get(0));
mockMvc.perform(get("/api/posts/1"))
.andExpect(status().isOk())
.andExpect(content().json(jsonResponse));
}
@Test
void 유효하지_않은_id로_Post_불러오기() throws Exception {
when(postRepository.findById(999)).thenThrow(new PostNotFoundException());
mockMvc.perform(get("/api/posts/999"))
.andExpect(status().isNotFound());
}
@Test
void 유효한_객체로_Post_생성하기() throws Exception {
var post = new Post(3, 1, "New Title", "New Body", null);
when(postRepository.save(any(Post.class))).thenReturn(post);
String jsonRequest = objectMapper.writeValueAsString(post);
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isCreated())
.andExpect(content().json(jsonRequest));
}
@Test
void 유효하지_않은_객체로_Post_생성하기() throws Exception {
var post = new Post(2, 1, "", "", null);
String jsonRequest = objectMapper.writeValueAsString(post);
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isBadRequest());
}
@Test
void 유효한_객체로_Post_업데이트하기() throws Exception {
var updated = new Post(1, 1, "This is a new title", "This is a new body", 1);
when(postRepository.findById(1)).thenReturn(Optional.of(posts.get(0)));
when(postRepository.save(any(Post.class))).thenReturn(updated);
String jsonRequest = objectMapper.writeValueAsString(updated);
mockMvc.perform(put("/api/posts/1")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isOk())
.andExpect(content().json(jsonRequest));
}
@Test
void 유효하지_않은_객체로_Post_업데이트하기() throws Exception {
var updated = new Post(1, 1, "", "", 1);
String jsonRequest = objectMapper.writeValueAsString(updated);
mockMvc.perform(put("/api/posts/1")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isBadRequest());
}
@Test
void 유효한_id로_Post_삭제하기() throws Exception {
when(postRepository.existsById(1)).thenReturn(true);
doNothing().when(postRepository).deleteById(1);
mockMvc.perform(delete("/api/posts/1"))
.andExpect(status().isNoContent());
verify(postRepository, times(1)).deleteById(1);
}
@Test
void 유효하지_않은_id로_Post_삭제하기() throws Exception {
doThrow(new PostNotFoundException()).when(postRepository).existsById(999);
mockMvc.perform(delete("/api/posts/999"))
.andExpect(status().isNotFound());
verify(postRepository, times(1)).existsById(999);
}
}
추가적으로 테스트 메서드 이름을 일관성있게 만들었습니다.
TDD에 대해 학습하면서 아래 항목을 고려해야 한다고 느꼈습니다.
1. 어떤 기능을 테스트할 것인가?(작은 단위부터)
게시글 조회, 생성, 수정, 삭제 등의 기능을 테스트했습니다.
DB와 직접적인 연결이 없는 mock 객체로 테스트 하는 것부터,
container를 활용한 통한 테스트까지 진행했습니다.
2. 어떤 반응(데이터 변환, 상태 코드)을 기대하는가?
데이터의 변환이 기대에 맞게 일어났는지,
데이터의 변환에 따라 적절한 상태코드를 반환하도록 설정해야 합니다.
(2xx이 성공에 대한 것이고 4xx이 클라이언트 예외에 대한 것, 5xx이 서버 예외에 대한 것 등)
3. 알아볼 수 있도록 만들어졌는가?
메서드 이름 등 유지보수를 고려하여 리팩토링을 진행합니다.