들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다.
### JPA Buddy
.jpb/
@GetMapping("/")
public String root() {
return "forward:/articles";
}
}
@DisplayName("View 컨트롤러 - 리다이렉션")
@Import(SecurityConfig.class)
@WebMvcTest(MainController.class)
class MainControllerTest {
private final MockMvc mvc;
public MainControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@DisplayName("[VIEW][GET] 루트 페이지 -> 게시판 페이지로 포워딩")
@Test
void givenNothing_whenRequestingRootPage_thenRedirectsToArticlePage() throws Exception {
// Given
// When & Then
mvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(view().name("forward:/articles"))
.andExpect(forwardedUrl("/articles"))
.andDo(MockMvcResultHandlers.print());
}
}
package com.fastcampus.projectboard.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
@Getter
//@Setter : 클래스 전체적으로 걸면 안됨 -> (id) 등을 setter로 변경해버릴 수 있음
//물론 case by case로 전체적으로 거는 경우도 있음
@ToString(callSuper = true) // alt+enter로 ToString.Exclude 활성화
// 본문을 index로 하기엔 너무 큼 (MySQL 자체 기능이나 ElasticSearch 등의 검색 엔진 사용)
@Table(indexes = {
@Index(columnList = "title"),
@Index(columnList = "hashtag"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class Article extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto_increment
private Long id;
// 변경사항
@Setter @ManyToOne(optional = false) private UserAccount userAccount; // 유저 정보 (ID)
@Setter @Column(nullable = false) private String title; // 제목
@Setter @Column(nullable = false, length = 10000) private String content; // 본문
@Setter private String hashtag; // 해시태그
// @Transient 언급이 없는 이상 @Column 적용된 것으로 인식 (nullable = true)
@OrderBy("createdAt DESC") // 생성일자 기준 정렬
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@ToString.Exclude // 위의 ToString alt+enter로 ToString.Exclude 활성화
// 실무에서는 양방향 바인딩을 푸는 경우가 많음 (운영상 이슈 등)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();
// Entity로서의 기본 기능 추가
// Hibernate 구현체 기준으로 기본 생성자가 필요 (외부에서는 사용하지 못하도록 해야 함)
protected Article() {} // private는 안됨
/* private Article(String title, String content, String hashtag) {
this.title = title;
this.content = content;
this.hashtag = hashtag;
}*/
private Article(UserAccount userAccount, String title, String content, String hashtag) {
this.userAccount = userAccount;
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
/* public static Article of(String title, String content, String hashtag) {
return new Article(title, content, hashtag);
}*/
public static Article of(UserAccount userAccount, String title, String content, String hashtag) {
return new Article(userAccount, title, content, hashtag);
}
// 도메인 Article을 생성하려면 위의 매개값이 필요하다는 가이드
// list, collection 에 넣고 사용할 때 동일성 / 동등성 확인 필요
// Lombok의 @EqualsAndHashCode 사용 x, 사용하게 되면 모든 필드가 동일해야 동일한 객체라고 판단하게 되는데 그럴 필요 없음
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Article article)) return false;
return id != null && id.equals(article.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
package com.fastcampus.projectboard.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.Objects;
@Getter
//@Setter : 클래스 전체적으로 걸면 안됨 -> (id) 등을 setter로 변경해버릴 수 있음
//물론 case by case로 전체적으로 거는 경우도 있음
@ToString(callSuper = true)
// 본문을 index로 하기엔 너무 큼 (MySQL 자체 기능이나 ElasticSearch 등의 검색 엔진 사용)
@Table(indexes = {
@Index(columnList = "content"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class ArticleComment extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto_increment
private Long id;
@Setter @ManyToOne(optional = false) private Article article;
// 게시글 (ID) ManyToOne : 상관관계, optional = false : 해당 필드가 필수로 있어야 함 (casecading = none이 기본)
@Setter @ManyToOne(optional = false) private UserAccount userAccount; // 유저 정보 (ID)
@Setter @Column(nullable = false, length = 500) private String content; // 본문
protected ArticleComment() {}
/* public ArticleComment(Article article, String content) {
this.article = article;
this.content = content;
} // @NoArgsConstructor로 대체 가능 */
public ArticleComment(Article article, UserAccount userAccount, String content) {
this.article = article;
this.userAccount = userAccount;
this.content = content;
} // @NoArgsConstructor로 대체 가능
/* public static ArticleComment of(Article article, String content) {
return new ArticleComment(article, content);
}*/
public static ArticleComment of(Article article, UserAccount userAccount, String content) {
return new ArticleComment(article, userAccount, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ArticleComment that)) return false;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
package com.fastcampus.projectboard.repository;
import com.fastcampus.projectboard.config.JpaConfig;
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.UserAccount;
import org.h2.engine.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("JPA 연결 테스트")
@Import(JpaConfig.class) // 아래의 어노테이션이 인식을 못하므로 수동 Import
@DataJpaTest
class JpaRepositoryTest {
private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
// 추가된 사항
private final UserAccountRepository userAccountRepository;
public JpaRepositoryTest(@Autowired ArticleRepository articleRepository,
@Autowired ArticleCommentRepository articleCommentRepository,
@Autowired UserAccountRepository userAccountRepository
) {
this.articleRepository = articleRepository;
this.articleCommentRepository = articleCommentRepository;
this.userAccountRepository = userAccountRepository;
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine() {
// Given
// When
List<Article> articles = articleRepository.findAll();
// Then
assertThat(articles)
.isNotNull()
.hasSize(123);
}
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count(); // 현재 repository의 aritcle 개수 카운트
// 추가됨
UserAccount userAccount = userAccountRepository.save(UserAccount.of("mrcocoball", "pw", null, null, null));
Article article = Article.of(userAccount, "new article", "new content", "#spring");
// When
// Article savedArticle = articleRepository.save(Article.of("new article", "new content", "#spring"));
articleRepository.save(article);
// repository에 새 article 추가
// Then
assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
}
@DisplayName("update 테스트")
@Test
void givenTestData_whenUpdating_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
// 현재 repository 내부에서 id가 1l인 article 불러오기, 없으면 exception throw
String updatedHashTag = "#springboot";
// 업데이트할 해시태그
article.setHashtag(updatedHashTag);
// 해시태그 수정
// When
Article savedArticle = articleRepository.saveAndFlush(article);
// save로만 할 경우 update 쿼리가 관측이 안됨
// Then
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashTag);
}
@DisplayName("delete 테스트")
@Test
void givenTestData_whenDeleting_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
// 현재 repository 내부에서 id가 1l인 article 불러오기, 없으면 exception throw
long previousArticleCount = articleRepository.count();
long previousArticleCommentCount = articleCommentRepository.count();
// 연관관계가 지정되어 있어 article 삭제 시 articlecomment도 같이 지워지므로 둘 다 개수 카운트
int deletedCommentsSize = article.getArticleComments().size();
// When
articleRepository.delete(article);
// Then
assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1);
assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize);
}
}
import com.fastcampus.projectboard.domain.Article;
import java.time.LocalDateTime;
public record ArticleDto(
// Entity로 받아올 데이터
Long id,
UserAccountDto userAccountDto,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleDto of(Long id, UserAccountDto userAccountDto, String title, String content, String hashtag, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleDto(id, userAccountDto, title, content, hashtag, createdAt, createdBy, modifiedAt, modifiedBy);
}
// Entity -> dto로 변환
public static ArticleDto from(Article entity) {
return new ArticleDto(
entity.getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getTitle(),
entity.getContent(),
entity.getHashtag(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
// dto -> Entity로 변환
public Article toEntity() {
return Article.of(
userAccountDto.toEntity(),
title,
content,
hashtag
);
}
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.ArticleComment;
import java.time.LocalDateTime;
public record ArticleCommentDto(
Long id,
Long articleId,
UserAccountDto userAccountDto,
String content,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleCommentDto(id, articleId, userAccountDto, content, createdAt, createdBy, modifiedAt, modifiedBy);
}
// 추가
public static ArticleCommentDto from(ArticleComment entity) {
return new ArticleCommentDto(
entity.getId(),
entity.getArticle().getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getContent(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public ArticleComment toEntity(Article entity) {
return ArticleComment.of(
entity,
userAccountDto.toEntity(),
content
);
}
}
import com.fastcampus.projectboard.domain.UserAccount;
import java.time.LocalDateTime;
public record UserAccountDto(
Long id,
String userId,
String userPassword,
String email,
String nickname,
String memo,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static UserAccountDto of(Long id, String userId, String userPassword, String email, String nickname, String memo, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new UserAccountDto(id, userId, userPassword, email, nickname, memo, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static UserAccountDto from(UserAccount entity) {
return new UserAccountDto(
entity.getId(),
entity.getUserId(),
entity.getUserPassword(),
entity.getEmail(),
entity.getNickname(),
entity.getMemo(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public UserAccount toEntity() {
return UserAccount.of(
userId,
userPassword,
email,
nickname,
memo
);
}
}
import com.fastcampus.projectboard.domain.Article;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
public record ArticleWithCommentsDto(
Long id,
UserAccountDto userAccountDto,
Set<ArticleCommentDto> articleCommentDtos,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleWithCommentsDto of(Long id, UserAccountDto userAccountDto, Set<ArticleCommentDto> articleCommentDtos, String title, String content, String hashtag, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleWithCommentsDto(id, userAccountDto, articleCommentDtos, title, content, hashtag, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static ArticleWithCommentsDto from(Article entity) {
return new ArticleWithCommentsDto(
entity.getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getArticleComments().stream()
.map(ArticleCommentDto::from)
.collect(Collectors.toCollection(LinkedHashSet::new)),
entity.getTitle(),
entity.getContent(),
entity.getHashtag(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.UserAccount;
import com.fastcampus.projectboard.domain.type.SearchType;
import com.fastcampus.projectboard.dto.ArticleDto;
import com.fastcampus.projectboard.dto.ArticleWithCommentsDto;
import com.fastcampus.projectboard.dto.UserAccountDto;
import com.fastcampus.projectboard.repository.ArticleRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.*;
@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
@InjectMocks private ArticleService sut; // Mock을 주입해주는 대상에만 @InjectMocks
@Mock private ArticleRepository articleRepository; // InjectMocks 외의 모든 대상
@DisplayName("READ - 게시글 조회 시 게시글을 반환")
@Test
void givenArticleId_whenSearchingArticle_thenReturnsArticle() {
// Given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// When
ArticleWithCommentsDto dto = sut.getArticle(articleId);
// Then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtag", article.getHashtag());
then(articleRepository).should().findById(articleId);
}
@DisplayName("READ - 검색어 없이 게시글을 검색하면, 게시글 페이지를 반환")
@Test
void givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
// Given
Pageable pageable = Pageable.ofSize(20);
given(articleRepository.findAll(pageable)).willReturn(Page.empty());
// When
Page<ArticleDto> articles = sut.searchArticles(null, null, pageable);
// Then
assertThat(articles).isEmpty();
then(articleRepository).should().findAll(pageable);
}
@DisplayName("READ - 검색어와 함께 게시글을 검색하면, 게시글 페이지를 반환")
@Test
void givenSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
// Given
SearchType searchType = SearchType.TITLE;
String searchKeyword = "title";
Pageable pageable = Pageable.ofSize(20);
given(articleRepository.findByTitleContaining(searchKeyword, pageable)).willReturn(Page.empty());
// When
Page<ArticleDto> articles = sut.searchArticles(searchType, searchKeyword, pageable);
// Then
assertThat(articles).isEmpty();
then(articleRepository).should().findByTitleContaining(searchKeyword, pageable);
}
@DisplayName("READ - 없는 게시글을 조회하면, 예외를 던짐")
@Test
void givenNonexistentArticleId_whenSearchingArticle_thenThrowsException() {
// Given
Long articleId = 0L;
given(articleRepository.findById(articleId)).willReturn(Optional.empty());
// When
// catchThrowable : Assertions.catchThrowable
Throwable t = catchThrowable(() -> sut.getArticle(articleId));
// Then
assertThat(t)
.isInstanceOf(EntityNotFoundException.class)
.hasMessage("게시글이 없습니다 - articleId: " + articleId);
then(articleRepository).should().findById(articleId);
}
@DisplayName("CREATE - 게시글 정보 입력 시 게시글을 생성")
@Test
void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
ArticleDto dto = createArticleDto();
given(articleRepository.save(any(Article.class))).willReturn(createArticle());
// When
sut.saveArticle(dto);
// Then
then(articleRepository).should().save(any(Article.class));
}
@DisplayName("UPDATE - 게시글의 수정 정보 입력 시 게시글을 수정")
@Test
void givenArticleModifiedInfo_whenUpdatingArticle_thenUpdatesArticle() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
Article article = createArticle();
ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");
given(articleRepository.getReferenceById(dto.id())).willReturn(article);
// When
sut.updateArticle(dto);
// Then
assertThat(article)
.hasFieldOrPropertyWithValue("title", dto.title())
.hasFieldOrPropertyWithValue("content", dto.content())
.hasFieldOrPropertyWithValue("hashtag", dto.hashtag());
then(articleRepository).should().getReferenceById(dto.id());
}
@DisplayName("UPDATE - 없는 게시글의 수정 정보를 입력하면, 경고 로그를 찍고 아무 것도 하지 않음")
@Test
void givenNonexistentArticleInfo_whenUpdatingArticle_thenLogsWarningAndDoesNothing() {
// Given
ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");
given(articleRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class);
// When
sut.updateArticle(dto);
// Then
then(articleRepository).should().getReferenceById(dto.id());
}
@DisplayName("DELETE - 게시글의 ID 입력 시 게시글을 삭제")
@Test
void givenArticleId_whenDeletingArticle_thenDeleteArticle() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
Long articleId = 1L;
willDoNothing().given(articleRepository).deleteById(articleId);
// When
sut.deleteArticle(1L);
// Then
then(articleRepository).should().deleteById(articleId);
}
private UserAccount createUserAccount() {
return UserAccount.of(
"uno",
"password",
"uno@email.com",
"Uno",
null
);
}
private Article createArticle() {
return Article.of(
createUserAccount(),
"title",
"content",
"#java"
);
}
// 테스트에 활용할 객체를 만드는 메소드
private ArticleDto createArticleDto() {
return createArticleDto("title", "content", "#java");
}
private ArticleDto createArticleDto(String title, String content, String hashtag) {
return ArticleDto.of(1L,
createUserAccountDto(),
title,
content,
hashtag,
LocalDateTime.now(),
"Uno",
LocalDateTime.now(),
"Uno");
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
1L,
"uno",
"password",
"uno@mail.com",
"Uno",
"This is memo",
LocalDateTime.now(),
"uno",
LocalDateTime.now(),
"uno"
);
}
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.QArticle;
import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer;
import org.springframework.data.querydsl.binding.QuerydslBindings;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
QuerydslPredicateExecutor<Article>, // Article 안의 모든 필드에 대한 기본 검색 기능 추가
QuerydslBinderCustomizer<QArticle> { // 커스컴 검색 기능 추가를 위해 활성화
Page<Article> findByTitle(String title, Pageable pageable);
@Override
default void customize(QuerydslBindings bindings, QArticle root) {
bindings.excludeUnlistedProperties(true); // true로 리스팅하지 않은 필드에 대해서는 검색 기능 열지 않게 함
bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy); // 해당 필드에 대해서는 검색 기능 오픈
// bindings.bind(root.content).first(StringExpression::likeIgnoreCase); // like '${v}'
// 대소문자까지 일치해야 검색되는 부분 해제
bindings.bind(root.title).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.content).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
import com.fastcampus.projectboard.domain.type.SearchType;
import com.fastcampus.projectboard.dto.ArticleDto;
import com.fastcampus.projectboard.dto.ArticleWithCommentsDto;
import com.fastcampus.projectboard.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType title, String search_keyword, Pageable pageable) {
return Page.empty(); // 아직 실제 페이지가 구현되지 않아 null 처리
}
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticle(Long articleId) {
return null; // 아직 실제 페이지가 구현되지 않아 null 처리
}
public void saveArticle(ArticleDto dto) {
}
public void updateArticle(ArticleDto dto) {
}
public void deleteArticle(Long articleId) {
}
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.ArticleComment;
import com.fastcampus.projectboard.domain.UserAccount;
import com.fastcampus.projectboard.dto.ArticleCommentDto;
import com.fastcampus.projectboard.dto.UserAccountDto;
import com.fastcampus.projectboard.repository.ArticleCommentRepository;
import com.fastcampus.projectboard.repository.ArticleRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.*;
@DisplayName("비즈니스 로직 - 댓글")
@ExtendWith(MockitoExtension.class)
class ArticleCommentServiceTest {
@InjectMocks
private ArticleCommentService sut; // Mock을 주입해주는 대상에만 @InjectMocks
@Mock
private ArticleCommentRepository articleCommentRepository; // InjectMocks 외의 모든 대상
@Mock
private ArticleRepository articleRepository;
@DisplayName("READ - 게시글 ID로 조회 시 해당 게시글의 댓글 리스트 반환")
@Test
void givenArticleId_whenSearchingComments_thenReturnsComments() {
// Given
Long articleId = 1L;
// static import [ given = BDDMockito.given]
ArticleComment expected = createArticleComment("content");
given(articleCommentRepository.findByArticle_Id(articleId)).willReturn(List.of(expected));
// articleRepository의 article 엔티티를 articleId로 찾을 때 title, content, #java를 리턴하게끔
// When
List<ArticleCommentDto> actual = sut.searchArticleComment(articleId); // 댓글 리스트
// Then
assertThat(actual)
.hasSize(1)
.first().hasFieldOrPropertyWithValue("content", expected.getContent());
then(articleCommentRepository).should().findByArticle_Id(articleId); // articleCommentRepository에서 findByArticle_Id를 호출해야 한다
}
@DisplayName("CREATE - 댓글 정보 입력 시 댓글 저장")
@Test
void givenArticleCommentInfo_whenSavingComment_thenSavesComment() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willReturn(createArticle());
given(articleCommentRepository.save(any(ArticleComment.class))).willReturn(null);
// articleCommentRepository에 ArticleComment.class의 아무거나 저장하고 null을 리턴
// When
sut.saveArticleComment(dto);
// Then
then(articleRepository).should().getReferenceById(dto.articleId());
then(articleCommentRepository).should().save(any(ArticleComment.class)); // articleRepository의 save가 호출되어야 한다!
}
@DisplayName("CREATE - 댓글 저장을 시도했는데 맞는 게시글이 없으면, 경고 로그를 찍고 아무것도 안 함")
@Test
void givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing() {
// Given
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willThrow(EntityNotFoundException.class);
// When
sut.saveArticleComment(dto);
// Then
then(articleRepository).should().getReferenceById(dto.articleId());
then(articleCommentRepository).shouldHaveNoInteractions();
}
@DisplayName("UPDATE - 댓글 수정 정보 입력 시 댓글을 수정")
@Test
void givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment() {
// Given
String oldContent = "content";
String updatedContent = "댓글";
ArticleComment articleComment = createArticleComment(oldContent);
ArticleCommentDto dto = createArticleCommentDto(updatedContent);
given(articleCommentRepository.getReferenceById(dto.id())).willReturn(articleComment);
// When
sut.updateArticleComment(dto);
// Then
assertThat(articleComment.getContent())
.isNotEqualTo(oldContent)
.isEqualTo(updatedContent);
then(articleCommentRepository).should().getReferenceById(dto.id());
}
@DisplayName("DELETE - 댓글의 ID 입력 시 댓글을 삭제")
@Test
void givenArticleCommentId_whenDeletingArticleComment_thenDeleteArticleComment() {
// Given
Long articleCommentId = 1L;
willDoNothing().given(articleCommentRepository).deleteById(articleCommentId);
// When
sut.deleteArticleComment(articleCommentId);
// Then
then(articleCommentRepository).should().deleteById(articleCommentId);
}
// 테스트에 활용할 객체를 만드는 메소드
private ArticleCommentDto createArticleCommentDto(String content) {
return ArticleCommentDto.of(
1L,
1L,
createUserAccountDto(),
content,
LocalDateTime.now(),
"uno",
LocalDateTime.now(),
"uno"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
1L,
"uno",
"password",
"uno@mail.com",
"Uno",
"This is memo",
LocalDateTime.now(),
"uno",
LocalDateTime.now(),
"uno"
);
}
private ArticleComment createArticleComment(String content) {
return ArticleComment.of(
Article.of(createUserAccount(), "title", "content", "hashtag"),
createUserAccount(),
content
);
}
private UserAccount createUserAccount() {
return UserAccount.of(
"uno",
"password",
"uno@email.com",
"Uno",
null
);
}
private Article createArticle() {
return Article.of(
createUserAccount(),
"title",
"content",
"#java"
);
}
}
import com.fastcampus.projectboard.domain.ArticleComment;
import com.fastcampus.projectboard.domain.QArticleComment;
import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer;
import org.springframework.data.querydsl.binding.QuerydslBindings;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import java.util.List;
@RepositoryRestResource
public interface ArticleCommentRepository extends
JpaRepository<ArticleComment, Long>,
QuerydslPredicateExecutor<ArticleComment>,
QuerydslBinderCustomizer<QArticleComment> {
List<ArticleComment> findByArticle_Id(Long articleId);
// _와 연관관계 관련 사용법은 Spring Data 공식 문서에 있음
@Override
default void customize(QuerydslBindings bindings, QArticleComment root) {
bindings.excludeUnlistedProperties(true); // true로 리스팅하지 않은 필드에 대해서는 검색 기능 열지 않게 함
bindings.including(root.content, root.createdAt, root.createdBy); // 해당 필드에 대해서는 검색 기능 오픈
// 대소문자까지 일치해야 검색되는 부분 해제
// bindings.bind(root.content).first(StringExpression::likeIgnoreCase); // like '${v}'
bindings.bind(root.content).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
import com.fastcampus.projectboard.dto.ArticleCommentDto;
import com.fastcampus.projectboard.repository.ArticleCommentRepository;
import com.fastcampus.projectboard.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
private final ArticleCommentRepository articleCommentRepository;
private final ArticleRepository articleRepository;
@Transactional(readOnly = true)
public List<ArticleCommentDto> searchArticleComment(Long articleId) {
return List.of();
}
public void saveArticleComment(ArticleCommentDto dto) {
}
public void updateArticleComment(ArticleCommentDto dto) {
}
public void deleteArticleComment(Long articleCommentId) {
}
}
record
타입dto > response package
public record ArticleResponse(
Long id,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String email,
String nickname
) implements Serializable {
public static ArticleResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname) {
return new ArticleResponse(id, title, content, hashtag, createdAt, email, nickname);
}
public static ArticleResponse from(ArticleDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtag(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname
);
}
}
public record ArticleCommentResponse(
Long id,
String content,
LocalDateTime createdAt,
String email,
String nickname
) implements Serializable {
public static ArticleCommentResponse of(Long id, String content, LocalDateTime createdAt, String email, String nickname) {
return new ArticleCommentResponse(id, content, createdAt, email, nickname);
}
public static ArticleCommentResponse from(ArticleCommentDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleCommentResponse(
dto.id(),
dto.content(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname
);
}
}
public record ArticleWithCommentsResponse(
Long id,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String email,
String nickname,
Set<ArticleCommentResponse> articleCommentResponses
) implements Serializable {
public static ArticleWithCommentsResponses of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname, Set<ArticleCommentResponse> articleCommentResponses) {
return new ArticleWithCommentsResponse(id, title, content, hashtag, createdAt, email, nickname, articleCommentResponses);
}
public static ArticleWithCommentsResponse from(ArticleWithCommentsDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleWithCommentsResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtag(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname,
dto.articleCommentDtos().stream()
.map(ArticleCommentResponse::from)
.collect(Collectors.toCollection(LinkedHashSet::new))
);
}
}
jpa:
open-in-view: false
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword == null || searchKeyword.isBlank()) { // 검색 키워드가 없을 경우
return articleRepository.findAll(pageable).map(ArticleDto::from);
// ArticleDto 클래스의 from 메소드 값, 즉 Entity -> Dto화한 것을 매핑
}
return switch(searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID -> articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME -> articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtag(searchKeyword, pageable).map(ArticleDto::from);
};
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.QArticle;
import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer;
import org.springframework.data.querydsl.binding.QuerydslBindings;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
QuerydslPredicateExecutor<Article>, // Article 안의 모든 필드에 대한 기본 검색 기능 추가
QuerydslBinderCustomizer<QArticle> { // 커스컴 검색 기능 추가를 위해 활성화
Page<Article> findByTitleContaining(String title, Pageable pageable);
Page<Article> findByContentContaining(String content, Pageable pageable);
// 부분검색
Page<Article> findByUserAccount_UserIdContaining(String userId, Pageable pageable);
// 부분검색
Page<Article> findByUserAccount_NicknameContaining(String nickname, Pageable pageable);
// 부분검색
Page<Article> findByHashtag(String hashtag, Pageable pageable);
@Override
default void customize(QuerydslBindings bindings, QArticle root) {
bindings.excludeUnlistedProperties(true); // true로 리스팅하지 않은 필드에 대해서는 검색 기능 열지 않게 함
bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy); // 해당 필드에 대해서는 검색 기능 오픈
// bindings.bind(root.content).first(StringExpression::likeIgnoreCase); // like '${v}'
// 대소문자까지 일치해야 검색되는 부분 해제
bindings.bind(root.title).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.content).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticle(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleWithCommentsDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
public void saveArticle(ArticleDto dto) {
articleRepository.save(dto.toEntity()); // dto -> Entity로 변환 후 저장
}
public void updateArticle(ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(dto.id());
if (dto.title() != null) { article.setTitle(dto.title());} // record 타입에 getter/setter
if (dto.content() != null) { article.setContent(dto.content()); } // record 타입에 getter/setter
article.setHashtag(dto.hashtag());
// articleRepository.save(article); save 필요 없음 (Transactional)
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", dto); // @Slf4j
}
}
public void deleteArticle(Long articleId) {
articleRepository.deleteById(articleId);
}
@MockBean // mockito의 mock과 동일, @Autowired 불가, 필드에만 주입
private ArticleService articleService;
public ArticleControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@DisplayName("[VIEW][GET] 게시글 리스트 (게시판 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
// When & Then
mvc.perform(get("/articles"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/index")) // 뷰 이름 검사
.andExpect(model().attributeExists("articles")); // 내부에 값이 있는지 (이름을 articles로 지정)
then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
}
@DisplayName("[VIEW][GET] 게시글 상세 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// Given
Long articleId = 1L;
given(articleService.getArticle(articleId)).willReturn(createArticleWithCommentsDto());
// When & Then
mvc.perform(get("/articles/1"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/detail")) // 뷰 이름 검사
.andExpect(model().attributeExists("article")) // 내부에 값이 있는지 (이름을 articles로 지정)
.andExpect(model().attributeExists("articleComments")); // 댓글 리스트에도 값이 있어야 함
then(articleService).should().getArticle(articleId);
}
// 픽스처
private ArticleWithCommentsDto createArticleWithCommentsDto() {
return ArticleWithCommentsDto.of(
1L,
createUserAccountDto(),
Set.of(),
"title",
"content",
"#java",
LocalDateTime.now(),
"uno",
LocalDateTime.now(),
"uno"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(1L,
"uno",
"pw",
"uno@mail.com",
"Uno",
"memo",
LocalDateTime.now(),
"uno",
LocalDateTime.now(),
"uno"
);
}
private final ArticleService articleService;
@GetMapping
public String articles(
@RequestParam(required = false) SearchType searchType,
@RequestParam(required = false) String searchValue,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
ModelMap map
) {
map.addAttribute("articles", articleService.searchArticles(searchType, searchValue, pageable)
.map(ArticleResponse::from)); // dto를 response로 변환
return "articles/index";
}
@GetMapping("/{articleId}")
public String article(@PathVariable Long articleId, ModelMap map) {
ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId));
map.addAttribute("article", article);
map.addAttribute("articleComments", article.articleCommentsResponses());
return "articles/detail";
}
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6">제목</th>
<th class="hashtag col-3">해시태그</th>
<th class="user-id col">작성자</th>
<th class="created-at col">작성일</th>
</tr>
</thead>
<tbody>
<tr>
<td class="title"><a>첫글</a></td>
<td class="hashtag">#Java</td>
<td class="user-id">mrcocoball</td>
<td class="created-at"><time>2022-08-01</time></td>
</tr>
<tr>
<td>두번째</td>
<td>#Javascript</td>
<td>Jio</td>
<td>2022-08-02</td>
</tr>
<tr>
<td>세번째</td>
<td>#Python</td>
<td>HYK</td>
<td>2022-08-03</td>
</tr>
</tbody>
</table>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="#article-table">
<attr sel="tbody" th:remove="all-but-first"> <!-- tbody의 첫번째만 남기고 전부 지운다 -->
<attr sel="tr[0]" th:each="article : ${articles}"> <!-- tr의 0번째부터 순회하며 아래 요소에 대한 작업 진행-->
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" /> <!-- a 부분에 다음과 같은 양식으로 하이퍼링크 작성-->
<attr sel="td.hashtag" th:text="${article.hashtag}" />
<attr sel="td.user-id" th:text="${article.nickname}" />
<attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
</attr>
</attr>
</attr>
</thlogic>