스프링 - 지인 정보 관리 시스템
22 Jul 2021 | java spring패스트캠퍼스 스프링 강의 정리
JPA: Persistence 레이어 Controller: Presentation 레이어
02. HelloWorldController 생성
모름지기 모든 시작은 Hello World 찍기 인거 같다. 메인 패키지에서 하위에 controller 패키지를 만들어 준 후 HelloWorldController 자바 파일을 생성해서 입력해준다. @RestController라는 어노테이션은 스프링이 해당 클래스가 컨트롤러임을 알려준다. @GetMapping은 HTTP Method 중 GET임을 알려준다. 이후 커맨드에서 ./gradlew bootRun 입력해주면 웹 서버가 실행되고, http://localhost:8080/api/helloWorld를 입력해주면 HelloWorld가 표시된다.
@RestController
public class HelloWorldController {
@GetMapping(value = "/api/helloWorld")
public String helloWorld() {
return "HelloWorld";
}
IntelliJ 단축키 중 Command + B를 하면 클래스의 위치를 찾아서 보여준다.
웹 브라우저에서 말고 IntelliJ에서도 확인이 가능한데, 테스트 패키지에서 hello.http라는 파일을 만들어 준 후, 다음처럼 입력해 보자
GET http:localhost:8080/api/helloWorld
이후 실행을 해보면 리턴 값을 돌려받는 걸 확인할 수 있다.
03. MockMvc 테스트 만들기
Command + Shift + t를 입력하면 테스트를 만들 수 있는 단축키이다. ```java package com.fastcampus.javaallinone.project3.mycontact.controller;
import org.junit.jupiter.api.Test; // Junit5꺼 사용 import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest class HelloWorldControllerTest { @Autowired // Spring Context에 Bean 주입 private HelloWorldController helloWorldController;
private MockMvc mockMvc;
@Test
void helloWorld() { // System.out.println("test");
System.out.println(helloWorldController.helloWorld());
}
@Test
void mockMvcTest() throws Exception {
mockMvc = MockMvcBuilders.standaloneSetup(helloWorldController).build();
mockMvc.perform(
MockMvcRequestBuilders.get("/api/helloWorld"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("HelloWorld"));
} } ```
JPA
JPA는 ORM 표준 인터페이스이다. 이 인터페이스를 활용하면 내가 어떠한 DB를 사용해도 그에 맞는 쿼리로 처리해주어 아주 좋다. build.gradle에 의존성을 추가 해준다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
h2는 초경량 메모리 DB이다. 테스트나 캐싱용으로 많이 사용된다.
객체를 디비에 저장하기 위해서 Repository를 만들어 준다. 아래와 같이 JPaRepository를 상속받는 인터페이스를 만들어 주면 된다. 제너릭으로는 엔티티와 primary key의 값 타입을 넣어주면 된다.
package com.fastcampus.javaallinone.project3.mycontact.repository;
import com.fastcampus.javaallinone.project3.mycontact.domain.Person;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersonRepository extends JpaRepository<Person, Long> {
}
이후 테스트는 아래처럼 해보면 된다.
package com.fastcampus.javaallinone.project3.mycontact.repository;
import com.fastcampus.javaallinone.project3.mycontact.domain.Person;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class PersonRepositoryTest {
@Autowired
private PersonRepository personRepository;
@Test
void crud() {
Person person = new Person();
person.setName("seokju");
person.setAge(10);
personRepository.save(person);
// System.out.println(personRepository.findAll());
List<Person> people = personRepository.findAll();
Assertions.assertThat(people.size()).isEqualTo(1);
Assertions.assertThat(people.get(0).getName()).isEqualTo("seokju");
Assertions.assertThat(people.get(0).getAge()).isEqualTo(10);
}
}
Lombok - 1
IntelliJ에서 lombok을 사용하기 위해서는 환결설정에서 Build, Execution, Deployment -> Compiler -> Annotaion Processors에서 Enable annotaion processing을 체크해주어야 한다.
lombok을 사용하면 Getter, Setter, ToString을 편안하게 이용할 수 있다. 기존에 우리가 멤버변수를 만들고 나서는 일일히 Getter, Setter함수를 만들어 주어야 했다. 물론 IDE에서 단축키를 통해 편하게 만들 수도 있지만 이것 또한 일이다. 그런 불편함을 lombok이 해결해준다.
@Entity
@Getter
@Setter
public class Person {
@Id
@GeneratedValue // 자동 생성
private Long id;
private String name;
private int age;
private String hobby;
private String bloodType;
private String address;
멤버 변수마다 @Getter, @Setter 넣어주는 방법도 있지만, 위처럼 클래스 위에 입력하면 모든 변수가 적용된다.
다음은 ToString인데, ToString 또한 만약 변수가 추가되어 새로 넣어야 하면 이것 또한 노가다이다. lombok은 ToString도 자동으로 처리해준다.
@Entity
@Getter
@Setter
@ToString
public class Person {
@Id
@GeneratedValue // 자동 생성
private Long id;
private String name;
private int age;
private String hobby;
private String bloodType;
private String address;
private String phoneNumber;
@ToString 어노테이션을 넣어주면 클래스 내의 멤버변수들로 적절하게 만들어 준다. 만약 전화번호는 개인정보라 노출되지 않길 바란다면 예외로 둘 수 있다. 아래와 같이 두가지 방법이 있다.
// 1번 - 클래스 위의 ToString에 내용 추가
@ToString(exclude="phoneNumber")
// 2번 - 멤버변수 위에 Exclude 어노테이션을 추가
@ToString.Exclude
private String phoneNumber;
lombok - 2
자바 클래스는 기본적으로 매개변수가 없는 생성자를 가지고 있다. 생성자 또한 lombok을 통해 기본 생성자와 전체 매개변수를 가진 생성자, 또는 특정 생성자를 만들 수 있다.
- NoArgsConstructor: 매개 변수 없는 생성자
- AllArgsConstructor: 전체 매개변수 가진 생성자
- RequiredArgsConstructor: 특정 매개변수 생정자이다. 이거 같은 경우에는 멤버 변수 위에 @NonNull 어노테이션을 달아준다.
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
public class Person {
@Id
@GeneratedValue // 자동 생성
private Long id;
@NonNull
private String name;
@NonNull
private int age;
private String hobby;
또한 HashCode와 equlas 메소드도 오버라이딩 해서 재정의를 많이 해주는데 이 또한 lombok에서 제공해준다. 위에 @EqualsAndHashCode 어노테이션을 달아주면 필드에 맞게 값을 맞춰 줄 것이다.
@EqualsAndHashCode
public class Person{}
마지막으로 @Getter, @Setter, ToString, EqualsAndHashCode 등을 한번에 제공하는 어노테이션이 있는데 이것이 @Data 어노테이션이다. 기존꺼를 다 지우고 @Data만 적으면 동일하게 작동한다.
@Entity
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Data
public class Person {
@Id
@GeneratedValue // 자동 생성
private Long id;
@NonNull
private String name;
@NonNull
private Integer age;
private String hobby;
@NonNull
private String bloodType;
private String address;
private LocalDate birthday;
private String job;
@ToString.Exclude
private String phoneNumber;
}
JPA Relation
테이블 간 관계를 맺기 위해서 @OneToOne, @OneToMany 같은 어노테이션을 설정해야한다. 아래의 예제는 Block 엔티티를 연결하는 방법이다. CASACADETYPE과 orphanRemoval 등을 설정해주어야 삭제 시 같이 연쇄적으로 삭제되거나 업데이트 될 것이다. 자세한 내용은 추후에 정리해봐야겠다. FetchType EAGER는 조인을 통해 갖고오는 형태이고, LAZY는 실제 사용할 때 SELECT해온다.
public class Person {
@OneToOne(cascade = {CascadeType.ALL}, orphanRemoval = true)
@ToString.Exclude
private Block block;
}
JPA Query Method
레포지토리의 findAll과 stream을 적절히 조합하면 내가 원하는 데이터를 찾을 수 있지만 이것은 모든 데이터를 갖고오는 비효율을 초래한다. 따라서 JPA의 Query Method를 이용해서 적절한 값을 갖고 올 수 있도록 하는 것이 좋다. 메소드의 구문은 대략 이러하다.
findById
sql로 따지면 find는 select, by는 where, Id가 이제 컬럼명이 된다. repository에 직접 구현을 해보자.
public interface PersonRepository extends JpaRepository<Person, Long> {
// 이름으로 검색
List<Person> findByName(String name);
// Block이 null인 것들 검색
List<Person> findByBlockIsNull();
// 혈액형으로 검색
List<Person> findByBloodType(String bloodType);
// 날짜로 검색 Between의 경우 두 인자 사이에 포함된 값들을 검색한다.
List<Person> findByBirthdayBetween(LocalDate startDate, LocalDate endDate);
}
JPA @Query
위 Query Method를 사용해서 8월달이 생일인 친구들을 갖고오는 테스트를 만들어 보겠다.
@Test
void findByBirthdayBetween() {
givenPerson("seokju", 10, "A", LocalDate.of(1991, 8, 15));
givenPerson("david", 9, "B", LocalDate.of(1992, 7, 10));
givenPerson("dennis", 8, "O", LocalDate.of(1993, 1, 5));
givenPerson("sophia", 7, "AB", LocalDate.of(1994, 6, 30));
givenPerson("benny", 6, "A", LocalDate.of(1995, 8, 30));
List<Person> result = personRepository.findByBirthdayBetween(LocalDate.of(1991, 8, 1), LocalDate.of(1995, 8, 31));
result.forEach(System.out::println);
}
private void givenPerson(String name, int age, String bloodType) {
givenPerson(name, age, bloodType, null);
}
private void givenPerson(String name, int age, String bloodType, LocalDate birthday) {
Person person = new Person(name, age, bloodType);
person.setBirthday(birthday);
personRepository.save(person);
}
List
result = personRepository.findByBirthdayBetween(LocalDate.of(1991, 8, 1), LocalDate.of(1995, 8, 31));
이렇게만 보면 8월달 생일인 친구들을 갖고오는거 갖지만 Between이 무엇인가. 그 사이에 있는 데이터를 다 갖고오는 것이기 때문에 위에서 만든 친구들이 전부 갖고 와 질 것이다. 이렇게 되면 우리가 의도했던거와는 전혀 다른 방향으로 바뀌는 것이다. 이것을 @Embedded, @Embeddable, @Valid, @Query 어노테이션으로 해결해보자.
@Valid 어노테이션을 사용하려면 build.gradle dependencies에 implementation ‘org.springframework.boot:spring-boot-starter-validation’를 추가해주자.
먼저 domain/dto/Birthday.java를 생성하여 Person의 birthday를 조금 수정해 준다.
@Embeddable // Entity에 속해있는 DTO
@Data
@NoArgsConstructor
public class Birthday {
private int yearOfBirthday;
@Min(1)
@Max(12)
private int monthOfBirthday;
@Min(1)
@Max(31)
private int dayOfBirthday;
public Birthday(LocalDate birthday) {
this.yearOfBirthday = birthday.getYear();
this.monthOfBirthday = birthday.getMonthValue();
this.dayOfBirthday = birthday.getDayOfMonth();
}
}
이후 Person의 birthday를 다음과 같이 수정해준다.
@Valid
@Embedded
private Birthday birthday;
이렇게 하고 PersonRepository에 다음을 추가해준다.
@Query어노테이션을 활용하면 우리가 JPQL이라 해서 Java와 Sql문을 혼재해서 사용할 수 있게 해주는 역할을 한다. 이렇게 하여 월에 해당하는 조건을 추가하여 해당 월에 맞는 데이터를 추출할 수 있게 해준다.
native=true
를 사용하면 실제 sql문을 사용하게 된다. ?1이나 ?2는 메소드의 매개변수 순서를 가르키는 것이고, 추가로 @Param 어노테이션을 활용하면 ?숫자 대신 지정한 이름으로 쓸 수 있다.
// @Query(value = "select person from Person person where person.birthday.monthOfBirthday = ?1 and person.birthday.dayOfBirthday=?2") // jpql (Entity 기반 퀀리), ?1은 첫번째 인자
// native = true는 실제 SQl로
// @Query(value = "select * from person where month_of_birthday = :monthOfBirthday and day_of_birthday =:dayOfBirthday", nativeQuery = true)
@Query(value = "select person from Person person where person.birthday.monthOfBirthday = :monthOfBirthday")
List<Person> findByMonthOfBirthday(@Param("monthOfBirthday") int monthOfBirthday);
// List<Person> findByMonthOfBirthday(@Param("monthOfBirthday") int monthOfBirthday, @Param("dayOfBirthday") int dayOfBirthday);
JPA data.sql
test시 data.sql을 만들어 사용하면 편하게 데이터를 삽입하여 관리할 수 있다.
spring2.5부터는 위 내용을 이용하려면 applicaion.yml 같은 파일에 다음을 추가해준다. defer-datasource-initialization: true
test디렉토리에 resources 디렉토리를 만든 후 data.sql을 만든다.
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (1, 'martin', 10, 'A', 1991, 8, 15);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (2, 'david', 9, 'B', 1992, 7, 21);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (3, 'dennis', 8, 'O', 1993, 10, 15);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (4, 'sophia', 7, 'AB', 1994, 8, 31);
insert into person(`id`, `name`, `age`, `blood_type`, `year_of_birthday`, `month_of_birthday`, `day_of_birthday`) values (5, 'benny', 6, 'A', 1995, 12, 23);
insert into block(`id`, `name`) values (1, 'dennis');
insert into block(`id`, `name`) values (2, 'sophia');
update person set block_id = 1 where id = 3;
update person set block_id = 2 where id = 4
이런식으로 하여 repository에서 굳이 우리가 객체 생성없이 db에 있는 내용으로 실습할 수 있게 된다.
Controller
RESTful하게 사용하기 위한 어노테이션으로 GET과 관련된 어노테이션은 아래와 같다.
- @GetMapping
- @PathVariable
@RequestMapping(value = “url명”)을 클래스 위에 두면 전체적인 url로 해당 내용의 root url이 된다 보면 된다.
@RestController는 Rest컨트롤러임을 알려준다.
@GetMapping은 GET메소드로 오는 것에 대한 어노테이션이다.
메소드에서 @GetMapping(“/{id}”)와 매개변수 쪽에 @PathVariable을 사용하여 매칭시켜준다. 일반적인 GET URL양식은 http:url/api/person?id=1
형태로 끝에 물음표와 값이 들어가는데 REST적으로 사용하면 http://url/api/person/1
이런식으로 사용하게 된다.
@RequestMapping(value = "/api/person")
@RestController
public class PersonController {
@Autowired
private PersonService personService;
@GetMapping("/{id}")
public Person getPerson(@PathVariable Long id) {
return personService.getPerson(id);
}
}
POST
POST는 다음과 같이 입력해보면 된다. @PostMapping 어노테이션을 걸어주면 POST 처리가 된다. 보통 REST 방식의 처리는 Request body안의 내용가지고 처리하기에 매개변수란에 @RequestBody 어노테이션을 걸어주면 된다. 그리고 처리가 정상적으로 완려되면 200 코드를 보내주는데, @ResponseStatus어노테이션을 활용하면 내가 원하는 코드로 보낼 수 있다.
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 Response
public void postPerson(@RequestBody Person person) { // 어노테이션이 없으면 @RequestParam으로 처리
personService.put(person);
log.info("person -> {}", personRepository.findAll());
}
Put
Shift + fn + f6을 누르면 메소드내의 이름 바꿀 때 좋다.
- @PutMapping
수정하는 서비스 내용은 다음과 같다.
근데 이런식으로 하면 내가 업데이트 정보에서 누락된게 있으면 null로 되어버리는 경우도 있기 때문에 좋지 않은 코드이다.
@Transactional
public void modify(Long id, PersonDto personDto ) {
Person personAtDb = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));
if (!personAtDb.getName().equals(personDto.getName())) {
throw new RuntimeException("이름이 다릅니다.");
}
personAtDb.setName(personDto.getName());
personAtDb.setPhoneNumber(personDto.getPhoneNumber());
personAtDb.setJob(personDto.getJob());
if (personDto.getBirthday() != null) {
personAtDb.setBirthday(new Birthday(personDto.getBirthday()));
}
personAtDb.setAddress(personDto.getAddress());
personAtDb.setBloodType(personDto.getBloodType());
personAtDb.setHobby(personDto.getHobby());
personAtDb.setAge(personDto.getAge());
personRepository.save(personAtDb);
}
Person DTO를 만들어 주자
package com.fastcampus.javaallinone.project3.mycontact.controller.dto;
import lombok.Data;
import java.time.LocalDate;
@Data
public class PersonDto {
private String name;
private int age;
private String hobby;
private String bloodType;
private String address;
private LocalDate birthday;
private String job;
private String phoneNumber;
}
그러고 Person Entity에서 유효성 검사라 해야하나 암튼 set 메서드를 만들어 주자.
public void set(PersonDto personDto) {
if (personDto.getAge() != 0) { // personDTO에서 age를 primitive형으로 해서 가능
this.setAge(personDto.getAge());
}
if (!StringUtils.isEmpty(personDto.getHobby())) {
this.setHobby(personDto.getHobby());
}
if (!StringUtils.isEmpty(personDto.getBloodType())) {
this.setBloodType(personDto.getBloodType());
}
if (!StringUtils.isEmpty(personDto.getAddress())) {
this.setAddress(personDto.getAddress());
}
if (!StringUtils.isEmpty(personDto.getJob())) {
this.setAddress(personDto.getJob());
}
if (!StringUtils.isEmpty(personDto.getPhoneNumber())) {
this.setPhoneNumber(personDto.getPhoneNumber());
}
}
Patch
데이터의 일부만 수정할 때 쓰이는 메소드이다.
// Controller
@PatchMapping("/{id}") // 일부 리소스만 변경
public void modifyPerson(@PathVariable Long id, String name) {
personService.modify(id, name);
log.info("person -> {}", personRepository.findAll());
}
// Services
@Transactional
public void modify(Long id, String name) {
Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));
person.setName(name);
personRepository.save(person);
}
// Test
@Test
void modifyName() throws Exception {
mockMvc = MockMvcBuilders.standaloneSetup(personController).build();
mockMvc.perform(
MockMvcRequestBuilders.patch("/api/person/1")
.param("name", "martin22"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk());
}
DELETE
// Controller
@DeleteMapping("/{id}")
public void deletePerson(@PathVariable Long id) {
personService.delete(id);
log.info("person -> {}", personRepository.findAll());
}
// Services
@Transactional
public void delete(Long id) {
// Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));
// personRepository.delete(person);
personRepository.deleteById(id);
}
@Test
void deletePerson() throws Exception {
mockMvc.perform(
MockMvcRequestBuilders.delete("/api/person/1"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk());
}
이런식으로 지울 수도 있지만, 이런 경우 데이터 복구하기 어려워지기에 플래그를 활용하여 소프트하게 삭제를 한다. @Where(clause = “deleted = false”)는 findAll같은거 할 때 where절을 추가할 때 사용하는 것이다. @ColumnDefault(“0”)는 false로 저장할 때 사용되는 부분이다. 변경
// Entity
@Where(clause = "deleted = false")
public class Person {
@ColumnDefault("0")
private boolean deleted;
}
@Transactional
public void delete(Long id) {
Person person = personRepository.findById(id).orElseThrow(() -> new RuntimeException("아이디가 존재하지 않습니다."));
person.setDeleted(true);
personRepository.save(person);
}
Refactoring
테스트를 잘 만들면 변경내용이 적용되는 범위를 금방 찾고, 사이드이펙트를 체크해볼 수 있다. 오류는 컴파일 단계, 테스트 단계, 런타임 단계 순으로 빠르게 발생할 수 있도록 해야 좋은 코드이다.
Controller Test
Delete Controller에서 기존까지는 void로 반환했기에 삭제가 제대로 되었는지 알 수 없었다. 이를 해결하기 위한 방법으로는 크게 세가지 정도가 있다.
-
boolean 형태로 반환하기. 반환형을 void에서 boolean타입으로 지정하고 return true로 바꾼다. 그런데 만약 우리가 삭제하는 서비스 코드를 지워도 무조건 true로 반환하기 때문에 좋진 않다.
- 삭제됐는지 확인하여 true, false 반환
@DeleteMapping("/{id}") public boolean deletePerson(@PathVariable Long id) { personService.delete(id); log.info("person -> {}", personRepository.findAll()); return personRepository.findPeopleDeleted().stream().anyMatch(person -> person.getId().equals(id)); }
- 테스트에서 assert들을 활용하여 확인한다.
assertAll은 jUnit5의 기능인데, 한번에 assert들을 확인할 수 있다.
@Test
void modifyPerson() throws Exception {
PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
mockMvc.perform(
MockMvcRequestBuilders.put("/api/person/1")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(toJsonString(dto)))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk());
Person result = personRepository.findById(1L).get();
assertAll(
() -> assertThat(result.getName()).isEqualTo("martin"),
() -> assertThat(result.getHobby()).isEqualTo("programming"),
() -> assertThat(result.getAddress()).isEqualTo("seoul"),
() -> assertThat(result.getBirthday()).isEqualTo(Birthday.of(LocalDate.now())),
() -> assertThat(result.getJob()).isEqualTo("programmer"),
() -> assertThat(result.getPhoneNumber()).isEqualTo("010-1111-2222")
);
}
03
Requestbody 파라미터로 엔티티 객체를 받는 것은 안전하지 못한 방법이다. ID나 Delete관련해서 사용자가 보내면 안되기 때문이다. 그래서 Dto 객체를 파라미터로 지정하여 값을 받고 서버에서 엔티티 객체를 만들도록 하는 것이 좋다.
// Controller
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 201 Response
public void postPerson(@RequestBody PersonDto personDto) { // 어노테이션이 없으면 @RequestParam으로 처리
personService.put(personDto);
}
// Service
@Transactional
public void put(PersonDto personDto) {
Person person = new Person();
person.set(personDto);
person.setName(personDto.getName());
personRepository.save(person);
}
// Test
@Test
void postPerson() throws Exception {
PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
mockMvc.perform(
MockMvcRequestBuilders.post("/api/person")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(toJsonString(dto)))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isCreated());
Person result = personRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).get(0);
assertAll(
() -> assertThat(result.getName()).isEqualTo("martin"),
() -> assertThat(result.getHobby()).isEqualTo("programming"),
() -> assertThat(result.getAddress()).isEqualTo("seoul"),
() -> assertThat(result.getBirthday()).isEqualTo(Birthday.of(LocalDate.now())),
() -> assertThat(result.getJob()).isEqualTo("programmer"),
() -> assertThat(result.getPhoneNumber()).isEqualTo("010-1111-2222")
);
}
Repository Test
@Transactional
@SpringBootTest
class PersonRepositoryTest {
@Autowired
private PersonRepository personRepository;
@Test
void findByName() {
List<Person> people = personRepository.findByName("tony");
Assertions.assertThat(people.size()).isEqualTo(1);
Person person = people.get(0);
assertAll(
() -> Assertions.assertThat(person.getName()).isEqualTo("tony"),
() -> Assertions.assertThat(person.getHobby()).isEqualTo("reading"),
() -> Assertions.assertThat(person.getAddress()).isEqualTo("서울"),
() -> Assertions.assertThat(person.getBirthday()).isEqualTo(Birthday.of(LocalDate.of(1991, 7, 10))),
() -> Assertions.assertThat(person.getJob()).isEqualTo("officer"),
() -> Assertions.assertThat(person.getPhoneNumber()).isEqualTo("010-2222-5555"),
() -> Assertions.assertThat(person.isDeleted()).isEqualTo(false)
);
}
@Test
void findByNameIfDelted() {
List<Person> people = personRepository.findByName("andrew");
Assertions.assertThat(people.size()).isEqualTo(0);
}
@Test
void findByMonthOfBirthday() {
List<Person> people = personRepository.findByMonthOfBirthday(7);
Assertions.assertThat(people.size()).isEqualTo(2);
assertAll(
() -> Assertions.assertThat(people.get(0).getName()).isEqualTo("david"),
() -> Assertions.assertThat(people.get(1).getName()).isEqualTo("tony")
);
}
@Test
void findPeopleDeleted() {
List<Person> people = personRepository.findPeopleDeleted();
Assertions.assertThat(people.size()).isEqualTo(1);
Assertions.assertThat(people.get(0).getName()).isEqualTo("andrew");
}
}
Service Test
mockito 테스트로 테스트를 해보자. build.gradle에 의존성을 추가해주자.
testImplementation 'org.mockito:mockito-junit-jupiter'
PersonServiceTest를 아래와 바꿔서 실행해본다.
@ExtendWith(MockitoExtension.class)
class PersonServiceTest {
@InjectMocks
private PersonService personService;
@Mock
private PersonRepository personRepository;
@Test
void getPeopleByName() {
when(personRepository.findByName("martin"))
.thenReturn(Lists.newArrayList(new Person("martin")));
List<Person> result = personService.getPeopleByName("martin");
Assertions.assertThat(result.size()).isEqualTo(1);
Assertions.assertThat(result.get(0).getName()).isEqualTo("martin");
}
@Test
void getPerson() {
when(personRepository.findById(1L))
.thenReturn(Optional.of(new Person("martin")));
Person person = personService.getPerson(1L);
Assertions.assertThat(person.getName()).isEqualTo("martin");
}
@Test
void getPersonIfNotFound() {
when(personRepository.findById(1L))
.thenReturn(Optional.empty());
Person person = personService.getPerson(1L);
Assertions.assertThat(person).isNull();
}
@Test
void put() {
PersonDto dto = PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
personService.put(mockPersonDto());
verify(personRepository, times(1)).save(argThat(new IsPersonWillBeInserted()));
}
@Test
void modifyIfPersonNotFound() {
when(personRepository.findById(1L))
.thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () -> personService.modify(1L, mockPersonDto()));
}
@Test
void modifyIfNameIsDifferent() {
when(personRepository.findById(1L))
.thenReturn(Optional.of(new Person("tony")));
assertThrows(RuntimeException.class, () -> personService.modify(1L, mockPersonDto()));
}
@Test
void modify() {
when(personRepository.findById(1L))
.thenReturn(Optional.of(new Person("martin")));
personService.modify(1L, mockPersonDto());
// verify(personRepository, times(1)).save(any(Person.class)); // return void에 대한 확인
verify(personRepository, times(1)).save(argThat(new IsPersonWillBeUpdated()));
}
@Test
void modifyByNameIfPersonNotFound() {
when(personRepository.findById(1L))
.thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () -> personService.modify(1L, "daniel"));
}
@Test
void modifyByName() {
when(personRepository.findById(1L))
.thenReturn(Optional.of(new Person("martin")));
personService.modify(1L, "daniel");
verify(personRepository, times(1)).save(argThat(new IsNameWillBeUpdated()));
}
@Test
void deleteIfPersonNotFound() {
when(personRepository.findById(1L))
.thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () -> personService.delete(1L));
}
@Test
void delete() {
when(personRepository.findById(1L))
.thenReturn(Optional.of(new Person("martin")));
personService.delete(1L);
verify(personRepository, times(1)).save(argThat(new IsPersonWillBeDeleted()));
}
private PersonDto mockPersonDto() {
return PersonDto.of("martin", "programming", "seoul", LocalDate.now(), "programmer", "010-1111-2222");
}
private static class IsPersonWillBeInserted implements ArgumentMatcher<Person> {
@Override
public boolean matches(Person person) {
return equals(person.getName(), "martin")
&& equals(person.getHobby(), "programming")
&& equals(person.getAddress(), "seoul")
&& equals(person.getBirthday(), Birthday.of(LocalDate.now()))
&& equals(person.getJob(), "programmer")
&& equals(person.getPhoneNumber(), "010-1111-2222");
}
private boolean equals(Object actual, Object expected) {
return expected.equals(actual);
}
}
private static class IsPersonWillBeUpdated implements ArgumentMatcher<Person> {
@Override
public boolean matches(Person person) {
return equals(person.getName(), "martin")
&& equals(person.getHobby(), "programming")
&& equals(person.getAddress(), "seoul")
&& equals(person.getBirthday(), Birthday.of(LocalDate.now()))
&& equals(person.getJob(), "programmer")
&& equals(person.getPhoneNumber(), "010-1111-2222");
}
private boolean equals(Object actual, Object expected) {
return expected.equals(actual);
}
}
private static class IsNameWillBeUpdated implements ArgumentMatcher<Person> {
@Override
public boolean matches(Person person) {
return person.getName().equals("daniel");
}
}
private static class IsPersonWillBeDeleted implements ArgumentMatcher<Person> {
@Override
public boolean matches(Person person) {
return person.isDeleted();
}
}
Paging
많은 웹사이트들이 비단 제품 하나만 보는게 아니라 전체보기 같은 기능들을 제공한다. 그런데 정말로 서버에서 모든 데이터를 갖고오는 것은 비효율적이기 때문에 페이징 처리를 해야한다.
// Controller
@GetMapping
public Page<Person> getAll(@PageableDefault Pageable pageable) { // 기본 페이지 내용 설정
return personService.getAll(pageable);
}
// Service
public Page<Person> getAll(Pageable pageable) {
return personRepository.findAll(pageable);
}
// Test
@Test
void getAll() throws Exception {
mockMvc.perform(
MockMvcRequestBuilders.get("/api/person")
.param("page", "1")
.param("size", "2"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.totalPages").value(3)) // $ 해당 객체
.andExpect(MockMvcResultMatchers.jsonPath("$.totalElements").value(6))
.andExpect(MockMvcResultMatchers.jsonPath("$.numberOfElements").value(2))
.andExpect(MockMvcResultMatchers.jsonPath("$.content.[0].name").value("dennis"))
.andExpect(MockMvcResultMatchers.jsonPath("$.content.[1].name").value("sophia"));
}