본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #016] 북(Book) 도메인 개발 및 단위 테스트

by 뚜루리 2025. 4. 29.
728x90
320x100

🎯 오늘의 목표

  • 북(Book) 도메인 개발

⚙️ 진행한 작업

  • 북(Book) 엔티티 생성
  • 북(Book) 레파지토리 생성
  • 북(Book)  서비스 생성
  • 북(Book) 관련 예외 생성
  • 북(Book) 도메인 단위 테스트 

🛠️ 개발내용

📌  북(Book) 엔티티 생성

/**
 * 책(Book) 엔티티
 * - 외부 책 API에서 검색된 책 정보를 저장
 */
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 책 제목 */
    @Column(nullable = false)
    private String title;

    /** 저자 */
    private String author;

    /** 출판사 */
    private String publisher;

    /** ISBN */
    @Column(unique = true)
    private String isbn;

    /** 썸네일 이미지 URL */
    @Column(length = 1000)
    private String thumbnailUrl;

    /**
     * Book 생성 정적 메서드
     */
    public static Book create(String title, String author, String publisher, String isbn, String thumbnailUrl) {
        Book book = new Book();
        book.title = title;
        book.author = author;
        book.publisher = publisher;
        book.isbn = isbn;
        book.thumbnailUrl = thumbnailUrl;
        return book;
    }
}

 

 

📌 북(Book) 레파지토리 생성

package ddururi.bookbookclub.domain.book.repository;


import ddururi.bookbookclub.domain.book.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

/**
 * Book 데이터 액세스 레포지토리
 */
public interface BookRepository extends JpaRepository<Book, Long> {
    Optional<Book> findByIsbn(String isbn);

    Optional<Book> findByTitleAndAuthor(String title, String author);
}

 

 

📌 북(Book) 서비스 생성

package ddururi.bookbookclub.domain.book.service;

import ddururi.bookbookclub.domain.book.dto.BookRequest;
import ddururi.bookbookclub.domain.book.dto.BookResponse;
import ddururi.bookbookclub.domain.book.entity.Book;
import ddururi.bookbookclub.domain.book.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 책 관련 비즈니스 로직 처리
 */
@Service
@RequiredArgsConstructor
@Transactional
public class BookService {

    private final BookRepository bookRepository;

    /**
     * 책 등록
     * @param request 책 등록 요청 데이터
     * @return 등록된 책 정보
     */
    public BookResponse createBook(BookRequest request) {
        Book book = Book.create(
                request.getTitle(),
                request.getAuthor(),
                request.getPublisher(),
                request.getIsbn(),
                request.getThumbnailUrl()
        );
        Book savedBook = bookRepository.save(book);
        return new BookResponse(savedBook);
    }
}

 

 

📌 북(Book) 컨트롤러 생성

package ddururi.bookbookclub.domain.book.controller;

import ddururi.bookbookclub.domain.book.dto.BookRequest;
import ddururi.bookbookclub.domain.book.dto.BookResponse;
import ddururi.bookbookclub.domain.book.service.BookService;
import ddururi.bookbookclub.global.common.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
 * 책 관련 API 컨트롤러
 * - 등록
 */
@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
public class BookController {

    private final BookService bookService;

    /**
     * 책 등록 API
     * @param request 책 등록 요청 데이터
     * @return 등록된 책 정보
     */
    @PostMapping
    public ResponseEntity<ApiResponse<BookResponse>> createBook(
            @RequestBody @Valid BookRequest request
    ) {
        BookResponse bookResponse = bookService.createBook(request);
        return ResponseEntity.ok(ApiResponse.success(bookResponse));
    }
}

 

 

📌 BookRequest 생성

package ddururi.bookbookclub.domain.book.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
 * 책 등록 요청 DTO
 */
@Getter
@NoArgsConstructor
public class BookRequest {

    @NotBlank(message = "제목은 비어 있을 수 없습니다.")
    private String title;

    @NotBlank(message = "저자는 비어 있을 수 없습니다.")
    private String author;

    private String publisher;
    private String isbn;
    private String thumbnailUrl;
}

 

 

📌 BookResponse 생성

package ddururi.bookbookclub.domain.book.dto;

import ddururi.bookbookclub.domain.book.entity.Book;
import lombok.Getter;

@Getter
public class BookResponse {

    private final Long id;
    private final String title;
    private final String author;
    private final String publisher;
    private final String isbn;
    private final String thumbnailUrl;

    public BookResponse(Book book) {
        this.id = book.getId();
        this.title = book.getTitle();
        this.author = book.getAuthor();
        this.publisher = book.getPublisher();
        this.isbn = book.getIsbn();
        this.thumbnailUrl = book.getThumbnailUrl();
    }
}

 

 


📌 단위 테스트

package ddururi.bookbookclub.domain.book.service;

import ddururi.bookbookclub.domain.book.dto.BookRequest;
import ddururi.bookbookclub.domain.book.dto.BookResponse;
import ddururi.bookbookclub.domain.book.entity.Book;
import ddururi.bookbookclub.domain.book.repository.BookRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.extension.ExtendWith;

/**
 * BookService 단위 테스트
 */
@ExtendWith(MockitoExtension.class)
class BookServiceTest {

    @Mock
    private BookRepository bookRepository;

    @InjectMocks
    private BookService bookService;

    @Test
    @DisplayName("책 등록 성공")
    void createBook_success() {
        // given
        BookRequest request = new BookRequest();
        setField(request, "title", "Clean Code");
        setField(request, "author", "Robert C. Martin");
        setField(request, "publisher", "Prentice Hall");
        setField(request, "isbn", "9780132350884");
        setField(request, "thumbnailUrl", "https://example.com/cleancode.jpg");

        Book savedBook = Book.create(
                request.getTitle(),
                request.getAuthor(),
                request.getPublisher(),
                request.getIsbn(),
                request.getThumbnailUrl()
        );
        setField(savedBook, "id", 1L); // 저장된 책에 id 설정 (mock용)

        when(bookRepository.save(any(Book.class))).thenReturn(savedBook);

        // when
        BookResponse response = bookService.createBook(request);

        // then
        ArgumentCaptor<Book> captor = ArgumentCaptor.forClass(Book.class);
        verify(bookRepository, times(1)).save(captor.capture());
        Book capturedBook = captor.getValue();

        assertThat(response).isNotNull();
        assertThat(response.getId()).isEqualTo(1L);
        assertThat(capturedBook.getTitle()).isEqualTo(request.getTitle());
        assertThat(capturedBook.getAuthor()).isEqualTo(request.getAuthor());
        assertThat(capturedBook.getPublisher()).isEqualTo(request.getPublisher());
        assertThat(capturedBook.getIsbn()).isEqualTo(request.getIsbn());
        assertThat(capturedBook.getThumbnailUrl()).isEqualTo(request.getThumbnailUrl());
    }

    // 테스트용 private util (setter 없는 필드 강제 설정)
    private void setField(Object target, String fieldName, Object value) {
        try {
            var field = target.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(target, value);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

 

728x90
320x100