본문 바로가기
BackEnd/Project

[Board] Ch03. 게시글 페이지 기능 테스트 정의(1)

by 개발 Blog 2024. 8. 9.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

이번 포스팅에서는 Spring Boot 애플리케이션에서 게시글과 댓글 기능을 구현하고 테스트하는 방법을 다룬다. 이를 위해 서비스 계층을 작성하고, 비즈니스 로직을 테스트하며, 이를 바탕으로 도메인 타입과 DTO(Data Transfer Object)를 정의하는 과정을 설명한다.

 

서비스 코드

ArticleService

게시글을 검색, 조회, 생성, 수정, 삭제하는 비즈니스 로직을 포함한다.

@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
    ...

    public void saveArticle(ArticleDto dto) {
    }

    public void updateArticle(long articleId, ArticleUpdateDto dto) {
    }

    public void deleteArticle(long articleId) {
    }
}
  • saveArticle: 새로운 게시글을 저장하는 메서드다.
  • updateArticle: 특정 ID에 해당하는 게시글을 수정하는 메서드다.
  • deleteArticle: 특정 ID에 해당하는 게시글을 삭제하는 메서드다.

ArticleCommentService

댓글을 조회하고 저장하는 비즈니스 로직을 포함한다.

package org.example.projectboard.service;

import lombok.RequiredArgsConstructor;
import org.example.projectboard.dto.ArticleCommentDto;
import org.example.projectboard.repository.ArticleCommentRepository;
import org.example.projectboard.repository.ArticleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
    private final ArticleRepository articleRepository;
    private final ArticleCommentRepository articleCommentRepository;

    @Transactional(readOnly = true)
    public List<ArticleCommentDto> searchArticleComment(Long articleId) {
        return List.of();
    }

    public void saveArticleComment(ArticleCommentDto of) {
    }
}
  • searchArticleComment: 특정 게시글의 댓글들을 조회한다.
  • saveArticleComment: 새로운 댓글을 저장한다.

DTO 클래스

ArticleUpdateDto

게시글 수정 시 사용되는 DTO 클래스이다.

package org.example.projectboard.dto;

/**
 * DTO for {@link org.example.projectboard.domain.Article}
 */
public record ArticleUpdateDto(
        String title,
        String content,
        String hashtag
) {
  public static ArticleUpdateDto of (String title, String content, String hashtag) {
    return new ArticleUpdateDto(title, content, hashtag);
  }
}

 

서비스 테스트 코드

ArticleCommentServiceTest 클래스

댓글 서비스의 비즈니스 로직을 테스트한다.

package org.example.projectboard.service;

import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.ArticleComment;
import org.example.projectboard.dto.ArticleCommentDto;
import org.example.projectboard.repository.ArticleCommentRepository;
import org.example.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 java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.*;

@DisplayName("비즈니스 로직 - 댓글")
@ExtendWith(MockitoExtension.class)
class ArticleCommentServiceTest {

    @InjectMocks private ArticleCommentService sut;

    @Mock private ArticleRepository articleRepository;
    @Mock private ArticleCommentRepository articleCommentRepository;

    @DisplayName("게시글 ID로 조회하면, 해당하는 댓글 리스트를 반환한다.")
    @Test
    void givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments(){
        // given
        Long articleId = 1L;
        given(articleRepository.findById(articleId)).willReturn(Optional.of(
                Article.of("title", "content", "#java"))
        );

        // when
        List<ArticleCommentDto> articleComments = sut.searchArticleComment(articleId);

        // then
        assertThat(articleComments).isNotNull();
        then(articleRepository).should().findById(articleId);
    }

    @DisplayName("댓글 정보를 입력하면, 댓글을 저장한다.")
    @Test
    void givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment(){
        // given
        given(articleCommentRepository.save(any(ArticleComment.class))).willReturn(null);

        // when
        sut.saveArticleComment(ArticleCommentDto.of(LocalDateTime.now(), "Eunchan", LocalDateTime.now(), "Eunchan", "comment"));

        // then
        then(articleCommentRepository).should().save(any(ArticleComment.class));
    }
}
  • @ExtendWith(MockitoExtension.class): JUnit5와 Mockito를 통합하여 테스트를 실행한다.
  • @InjectMocks: 테스트 대상 클래스의 인스턴스를 생성하고 필요한 목 객체를 주입한다.
  • @Mock: 목 객체를 생성한다.
  • givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments: 특정 게시글 ID로 댓글을 조회하는 테스트 메서드.
  • givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment: 새로운 댓글을 저장하는 테스트 메서드.

ArticleServiceTest 클래스

게시글 서비스의 비즈니스 로직을 테스트한다.

package org.example.projectboard.service;

...

class ArticleServiceTest {

    ...

    @DisplayName("게시글 정보를 입력하면, 게시글을 생성한다.")
    @Test
    void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
        // given
        given(articleRepository.save(any(Article.class))).willReturn(null);

        // when
        sut.saveArticle(ArticleDto.of(LocalDateTime.now(), "Eunchan", "title", "content", "#java"));

        // then
        then(articleRepository).should().save(any(Article.class));
    }

    @DisplayName("게시글의 ID와 수정 정보를 입력하면, 게시글을 수정한다.")
    @Test
    void givenArticleIdAndModifiedInfo_whenUpdatingArticle_thenUpdatesArticle() {
        // given
        given(articleRepository.save(any(Article.class))).willReturn(null);

        // when
        sut.updateArticle(1L, ArticleUpdateDto.of("title", "content", "#java"));

        // then
        then(articleRepository).should().save(any(Article.class));
    }

    @DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다.")
    @Test
    void givenArticleId_whenDeletingArticle_thenDeletesArticle() {
        // given
        willDoNothing().given(articleRepository).delete(any(Article.class));

        // when
        sut.deleteArticle(1L);

        // then
        then(articleRepository).should().delete(any(Article.class));
    }
}
  • givenArticleInfo_whenSavingArticle_thenSavesArticle: 새로운 게시글을 저장하는 테스트 메서드.
  • givenArticleIdAndModifiedInfo_whenUpdatingArticle_thenUpdatesArticle: 특정 게시글을 수정하는 테스트 메서드.
  • givenArticleId_whenDeletingArticle_thenDeletesArticle: 특정 게시글을 삭제하는 테스트 메서드.

번외로 뷰 디자인을 업데이트하고 부족했던 템플릿을 추가하는 작업을 정리한다.

수정된 부분

1. article-content.css

게시글 본문에서 텍스트가 올바르게 줄 바꿈 되도록 하는 CSS 설정을 추가했다.

/* 게시글 본문 */
#article-content > pre {
    white-space: pre-wrap; /* Since CSS 2.1 */
    white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
    white-space: pre-wrap; /* Opera 4-6 */
    white-space: -o-pre-wrap; /* Opera 7 */
    word-wrap: break-word; /* Internet Explorer 5.5+ */
}

 

2. detail.html

게시글 상세 페이지를 업데이트했다. 주요 변경 사항은 댓글 리스트 부분의 ul 클래스 추가와 일부 레이아웃 수정이다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="Eunchan Kim">
    <title>게시글 페이지</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>

<header id="header"></header>
    <main class="container">
        <header class="py-5 text-center">
            <h1>첫 번째 글</h1>
        </header>

        <div class="row g-5">
            <section class="col-md-5 col-lg-4 order-md-last">
                <aside>
                    <p><span>Eunchan</span></p>
                    <p><a href="mailto:eunchan@gmail.com">eunchan@mail.com</a></p>
                    <p><time datetime="2024-01-01T00:00:00">2024-01-01</time></p>
                    <p><span>#java</span></p>
                </aside>
            </section>

            <article class="col-md-7 col-lg-8">
                <pre>본문<br><br></pre>
            </article>
        </div>

        <div class="row g-5">
            <section>
                <form class="row g-3">
                    <div class="col-8">
                        <label for="comment-textbox" hidden>댓글</label>
                        <textarea class="form-control" id="comment-textbox" placeholder="댓글 쓰기.." rows="3"></textarea>
                    </div>
                    <div class="col-auto">
                        <label for="comment-submit" hidden>댓글 쓰기</label>
                        <button class="btn btn-primary" id="comment-submit" type="submit">쓰기</button>
                    </div>
                </form>

                <ul class="row col-7">
                    <li>
                        <div>
                            <strong>Eunchan</strong>
                            <small><time>2024-01-01</time></small>
                            <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>Lorem ipsum dolor sit amet</p>
                        </div>
                    </li>
                    <li>
                        <div>
                            <strong>Eunchan</strong>
                            <small><time>2024-01-01</time></small>
                            <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>Lorem ipsum dolor sit amet</p>
                        </div>
                    </li>
                </ul>

        </section>
        </div>

        <div class="row g-5">
            <nav aria-label="Page navigation example">
                <ul class="pagination">
                    <li class="page-item">
                        <a class="page-link" href="#" aria-label="Previous">
                            <span aria-hidden="true">&laquo; prev</span>
                        </a>
                    </li>
                    <li class="page-item">
                        <a class="page-link" href="#" aria-label="Next">
                            <span aria-hidden="true">next &raquo;</span>
                        </a>
                    </li>
                </ul>
            </nav>
        </div>
    </main>

    <footer id="footer"></footer>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>

</html>

 

3. index.html

게시글 목록 페이지에서 날짜를 나타내는 <time> 태그를 추가했다.

...
<tbody>
<tr>
    <td>첫 글</td>
    <td>#java</td>
    <td>Eunchan</td>
    <td>2024-08-06</td>
</tr>
<tr>
    <td>두 번째 글</td>
    <td>#spring</td>
    <td>Eunchan</td>
    <td><time>2024-08-07</time></td>
</tr>
<tr>
    <td>세 번째 글</td>
    <td>#java</td>
    <td>Eunchan</td>
    <td><time>2024-08-08</time></td>
</tr>
</tbody>
...

 

추가된 부분

1. search-hashtag.html

해시태그 검색 페이지의 기본 템플릿을 추가했다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
게시글 해시태그 검색
</body>
</html>

 

2. search.html

게시글 검색 페이지의 기본 템플릿을 추가했다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
게시글 검색
</body>
</html>

 

3. sign-up.html

회원 가입 페이지의 기본 템플릿을 추가했다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>회원 가입</title>
</head>
<body>
<header>
    header 템플릿 삽입부
    <hr>
</header>

<form>
    <label for="userId">ID</label>
    <input id="userId" type="text" required>
    <label for="password">Password</label>
    <input id="password" type="password" required>
    <label for="email">Email</label>
    <input id="email" type="email" placeholder="you@example.com">
    <label for="nickname">Nickname</label>
    <input id="nickname" type="text">
    <label for="memo">메모</label>
    <textarea id="memo" rows="3"></textarea>
    <button type="submit">가입</button>
</form>

<footer>
    <hr>
    footer 템플릿 삽입부
</footer>

</body>
</html>

 

이번 작업에서는 게시글 페이지 기능 테스트 후 뷰 디자인을 개선하고 필요한 템플릿을 추가하여 프로젝트의 완성도를 높였다.

본문 텍스트 처리와 댓글 표시 방식 등을 개선해 더 직관적이고 깔끔한 인터페이스를 제공하게 되었다.