들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다.
<header id="header">
헤더 삽입부
<hr>
</header>
<main id="article-main" class="container">
<header id="article-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 id="nickname">Uno</span></p>
<p><a id="email" href="mailto:djkehh@gmail.com">uno@mail.com</a></p>
<p><time id="created-at" datetime="2022-01-01T00:00:00">2022-01-01</time></p>
<p><span id="hashtag">#java</span></p>
</aside>
</section>
<article id="article-content" 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 id="article-comments" class="row col-7">
<li>
<div>
<strong>Uno</strong>
<small><time>2022-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>Uno</strong>
<small><time>2022-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">« prev</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">next »</span>
</a>
</li>
</ul>
</nav>
</div>
</main>
<footer id="footer">
<hr>
푸터 삽입부
</footer>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="#article-main" th:object="${article}">
<attr sel="#article-header/h1" th:text="*{title}" /> <!-- *을 붙임으로서 앞쪽의 article.을 생략 -->
<attr sel="#nickname" th:text="*{nickname}" />
<attr sel="#email" th:text="*{email}" />
<attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="#hashtag" th:text="*{hashtag}" />
<attr sel="#article-content/pre" th:text="*{content}" />
</attr>
<attr sel="#article-comments" th:remove="all-but-first">
<attr sel="li[0]" th:each="articleComment : ${articleComments}">
<attr sel="div/strong" th:text="${articleComment.nickname}" />
<attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="div/p" th:text="${articleComment.content}" />
</attr>
</attr>
</thlogic>
@Service
public class PaginationService {
private static final int BAR_LENGTH = 5;
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
return null;
}
public int currentBarLength() {
return BAR_LENGTH;
}
}
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.*;
@DisplayName("비즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class) // 테스트의 무게가 줄어듬
class PaginationServiceTest {
private final PaginationService sut;
public PaginationServiceTest(@Autowired PaginationService paginationService) {
this.sut = paginationService;
}
@DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이지 바 리스트를 만들어줌")
@MethodSource // 메소드에 소스를 주는 방식
@ParameterizedTest(name = "[{index}] 현재 페이지 : {0}, 총 페이지 : {1} => {2}") // 파라미터 테스트
void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers(int currentPageNumber, int totalPages, List<Integer> expected) {
// Given
// When
List<Integer> actual = sut.getPaginationBarNumbers(currentPageNumber, totalPages);
// Then
assertThat(actual).isEqualTo(expected);
}
// @MethodSource를 클릭하여 생성
static Stream<Arguments> givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() {
return Stream.of(
// arguments = Arguments.arguments
arguments(0, 13, List.of(0,1,2,3,4)), // pagenumber, page의 index는 0부터 시작됨
arguments(1, 13, List.of(0,1,2,3,4)),
arguments(2, 13, List.of(0,1,2,3,4)),
arguments(3, 13, List.of(1,2,3,4,5)),
arguments(4, 13, List.of(2,3,4,5,6)),
arguments(5, 13, List.of(3,4,5,6,7)),
arguments(6, 13, List.of(4,5,6,7,8)),
arguments(10, 13, List.of(8,9,10,11,12)),
arguments(11, 13, List.of(9,10,11,12)),
arguments(12, 13, List.of(10,11,12)) // 13페이지까지이므로 index 기준은 12가 끝
);
}
@DisplayName("현재 설정되어 있는 페이지네이션 바의 길이를 알려줌")
@Test
void givenNothing_whenCalling_thenReturnsCurrentBarLength() {
// Given
// When
int barLength = sut.currentBarLength();
// Then
assertThat(barLength).isEqualTo(5); // 상수 처리한 바 길이 5를 명시적으로 알려주기 위해 해당 테스트 작성
}
}
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2), 0);
// 기본 골자는 현재 페이지 - (길이 / 2) 겠지만 0보다 작으면 0 반환
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
// startNumber + 길이가 totalPages를 넘지 않게
return IntStream.range(startNumber, endNumber).boxed().toList();
}
@MockBean // mockito의 mock과 동일, @Autowired 불가, 필드에만 주입
private ArticleService articleService;
@MockBean
private PaginationService paginationService;
@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());
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0,1,2,3,4));
// 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로 지정)
.andExpect(model().attributeExists("paginationBarNumbers")); // 내부에 값이 있는지 (이름을 paginationBarNumbers로 지정)
then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@DisplayName("[VIEW][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬 기능")
@Test
void givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() throws Exception {
// Given
String sortName = "title";
String direction = "desc";
int pageNumber = 0;
int pageSize = 5;
Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Order.desc(sortName)));
List<Integer> barNumbers = List.of(1, 2, 3, 4, 5);
given(articleService.searchArticles(null, null, pageable)).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages())).willReturn(barNumbers);
// When & Then
mvc.perform(
get("/articles")
.queryParam("page", String.valueOf(pageNumber))
.queryParam("size", String.valueOf(pageSize))
.queryParam("sort", sortName + "," + direction)
)
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attribute("paginationBarNumbers", barNumbers));
then(articleService).should().searchArticles(null, null, pageable);
then(paginationService).should().getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages());
}
@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
) {
Page<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable)
.map(ArticleResponse::from); // dto를 response로 변환하여 페이지로 만듬
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
// 현재 페이지 번호와 해당 페이지들의 전체 숫자(getTotalPages)를 매개값으로 페이징 내비게이션 바 구축
map.addAttribute("articles", articles);
map.addAttribute("paginationBarNumbers", barNumbers);
return "articles/index";
}
<nav id="pagination" aria-label="Page navigation example">
<ul class="pagination justify-content-center">
<li class="page-item"><a class="page-link" href="#">Previous</a></li>
<li class="page-item"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">Next</a></li>
</ul>
</nav>
<attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/> <!-- articles.number가 0보다 작거나 같으면 disabled -->
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/> <!-- pageNumber가 articles.number와 같으면 disabled -->
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/> <!-- articles.number가 전체 총합 페이지-1보다 크거나 같으면 disabled -->
</attr>
@DisplayName("게시글 수를 조회하면, 게시글 수를 반환한다")
@Test
void givenNothing_whenCountingArticles_thenReturnsArticleCount() {
// Given
long expected = 0L;
given(articleRepository.count()).willReturn(expected);
// When
long actual = sut.getArticleCount();
// Then
assertThat(actual).isEqualTo(expected);
then(articleRepository).should().count();
}
public long getArticleCount() {
return articleRepository.count();
}
@DisplayName("[VIEW][GET] 게시글 상세 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// Given
Long articleId = 1L;
Long totalCount = 1L;
given(articleService.getArticle(articleId)).willReturn(createArticleWithCommentsDto());
given(articleService.getArticleCount()).willReturn(totalCount);
// When & Then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andExpect(view().name("articles/detail")) // 뷰 이름 검사
.andExpect(model().attributeExists("article")) // 내부에 값이 있는지 (이름을 articles로 지정)
.andExpect(model().attributeExists("articleComments"))
.andExpect(model().attributeExists("articleComments"))
.andExpect(model().attribute("totalCount", totalCount));
then(articleService).should().getArticle(articleId);
then(articleService).should().getArticleCount();
}
@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());
map.addAttribute("totalCount", articleService.getArticleCount());
return "articles/detail";
}
<div class="row g-5">
<nav id="pagination" aria-label="Page navigation">
<ul class="pagination">
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">« prev</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">next »</span>
</a>
</li>
</ul>
</nav>
</div>
<attr sel="#article-main" th:object="${article}">
<attr sel="#article-header/h1" th:text="*{title}" />
<attr sel="#nickname" th:text="*{nickname}" />
<attr sel="#email" th:text="*{email}" />
<attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="#hashtag" th:text="*{hashtag}" />
<attr sel="#article-content/pre" th:text="*{content}" />
<attr sel="#article-comments" th:remove="all-but-first">
<attr sel="li[0]" th:each="articleComment : ${articleComments}">
<attr sel="div/strong" th:text="${articleComment.nickname}" />
<attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
<attr sel="div/p" th:text="${articleComment.content}" />
</attr>
</attr>
<attr sel="#pagination">
<attr sel="ul">
<attr sel="li[0]/a"
th:href="*{id} - 1 <= 0 ? '#' : |/articles/*{id - 1}|"
th:class="'page-link' + (*{id} - 1 <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]/a"
th:href="*{id} + 1 > ${totalCount} ? '#' : |/articles/*{id + 1}|"
th:class="'page-link' + (*{id} + 1 > ${totalCount} ? ' disabled' : '')"
/>
</attr>
</attr>
</attr>
@DisplayName("View 컨트롤러 - 인증")
@Import(SecurityConfig.class)
@WebMvcTest(Void.class)
public class AuthControllerTest {
private final MockMvc mvc;
public AuthControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@DisplayName("[VIEW][GET] 로그인 페이지 - 정상 호출")
@Test
public void givenNothing_whenTryingToLogin_thenReturnsLogInView() throws Exception {
// Given
// When & Then
mvc.perform(get("/login"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
.andDo(MockMvcResultHandlers.print());
}
}
<header id="header">
헤더 삽입부
<hr>
</header>
<main class="container">
<div class="row">
<div class="card card-margin search-form">
<div class="card-body p-0">
<form id="card search-form">
<div class="row">
<div class="col-12">
<div class="row no-gutters">
<div class="col-lg-3 col-md-3 col-sm-12 p-0">
<label for="search-type" hidden>검색 유형</label>
<select class="form-control" id="search-type">
<option>제목</option>
<option>본문</option>
<option>id</option>
<option>닉네임</option>
<option>해시태그</option>
</select>
</div>
<div class="col-lg-8 col-md-6 col-sm-12 p-0">
<label for="search-value" hidden>검색어</label>
<input type="text" placeholder="검색어..." class="form-control" id="search-value" name="search-value">
</div>
<div class="col-lg-1 col-md-3 col-sm-12 p-0">
<button type="submit" class="btn btn-base">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6"><a>제목</a></th>
<th class="hashtag col-2"><a>해시태그</a></th>
<th class="user-id col"><a>작성자</a></th>
<th class="created-at col"><a>작성일</a></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="main" th:object="${articles}">
<attr sel="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
</attr>
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
<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>
</attr>
<attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</thlogic>
/* 게시글 테이블 제목 */
#article-table > thead a {
text-decoration: none;
color: black;
}
<link href="/css/table-header.css" rel="stylesheet">