├── img ├── bdd_cycle.png ├── tdd_cycle.png ├── test_coverage.png ├── test_pyramid.png ├── 5_test_doubles.png └── nested_testcase_package.png ├── src ├── main │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── junhkang │ │ │ └── springboottesting │ │ │ ├── domain │ │ │ ├── OrderStatus.java │ │ │ ├── UserDTO.java │ │ │ ├── ProductDTO.java │ │ │ ├── User.java │ │ │ ├── Product.java │ │ │ ├── OrderDTO.java │ │ │ └── Order.java │ │ │ ├── repository │ │ │ ├── jpa │ │ │ │ ├── UserRepository.java │ │ │ │ ├── ProductRepository.java │ │ │ │ └── OrderRepository.java │ │ │ └── mybatis │ │ │ │ ├── UserMapper.java │ │ │ │ ├── ProductMapper.java │ │ │ │ └── OrderMapper.java │ │ │ ├── service │ │ │ ├── UserService.java │ │ │ ├── ProductService.java │ │ │ ├── OrderService.java │ │ │ └── impl │ │ │ │ ├── JpaProductServiceImpl.java │ │ │ │ ├── JpaUserServiceImpl.java │ │ │ │ ├── MyBatisUserServiceImpl.java │ │ │ │ ├── MyBatisProductServiceImpl.java │ │ │ │ ├── JpaOrderServiceImpl.java │ │ │ │ └── MyBatisOrderServiceImpl.java │ │ │ ├── SpringBootTestingFromZeroToHeroApplication.java │ │ │ ├── exception │ │ │ ├── ResourceNotFoundException.java │ │ │ └── GlobalExceptionHandler.java │ │ │ └── controller │ │ │ ├── UserController.java │ │ │ ├── ProductController.java │ │ │ └── OrderController.java │ └── resources │ │ ├── application.properties │ │ ├── mapper │ │ ├── UserMapper.xml │ │ ├── ProductMapper.xml │ │ └── OrderMapper.xml │ │ └── data.sql └── test │ └── java │ └── io │ └── github │ └── junhkang │ └── springboottesting │ ├── SpringBootTestingFromZeroToHeroApplicationTests.java │ ├── exception │ ├── ResourceNotFoundExceptionTest.java │ └── GlobalExceptionHandlerTest.java │ ├── controller │ ├── ProductControllerTest.java │ ├── UserControllerTest.java │ └── OrderControllerTest.java │ └── service │ └── impl │ ├── MyBatisProductServiceImplTest.java │ ├── MyBatisUserServiceImplTest.java │ ├── JpaUserServiceImplTest.java │ ├── JpaProductServiceImplTest.java │ ├── JpaOrderServiceImplTest.java │ └── MyBatisOrderServiceImplTest.java ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── .gitignore ├── LICENSE ├── REFERENCES.md ├── docs ├── 1.WHY - 왜 테스트를 작성해야 하는가?.md ├── 4.HOW DEEP - 얼마나 깊게 테스트 코드를 작성해야 하는가?.md ├── 3.WHEN - 언제 테스트 코드를 적용해야 하는가?.md └── 2.HOW - 테스트 코드를 어떻게 작성해야 하는가?.md ├── README.md ├── pom.xml ├── mvnw.cmd └── mvnw /img/bdd_cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/bdd_cycle.png -------------------------------------------------------------------------------- /img/tdd_cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/tdd_cycle.png -------------------------------------------------------------------------------- /img/test_coverage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/test_coverage.png -------------------------------------------------------------------------------- /img/test_pyramid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/test_pyramid.png -------------------------------------------------------------------------------- /img/5_test_doubles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/5_test_doubles.png -------------------------------------------------------------------------------- /img/nested_testcase_package.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junhkang/springboot-testing-from-zero-to-hero/HEAD/img/nested_testcase_package.png -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/OrderStatus.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | public enum OrderStatus { 4 | PENDING, 5 | COMPLETED, 6 | CANCELED 7 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/UserDTO.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class UserDTO { 7 | private Long id; 8 | private String username; 9 | private String email; 10 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/jpa/UserRepository.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.jpa; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface UserRepository extends JpaRepository { 7 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/ProductDTO.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ProductDTO { 7 | private Long id; 8 | private String name; 9 | private String description; 10 | private Double price; 11 | private Integer stock; 12 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/jpa/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.jpa; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ProductRepository extends JpaRepository { 7 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/UserService.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | 5 | import java.util.List; 6 | 7 | public interface UserService { 8 | List getAllUsers(); 9 | User getUserById(Long id); 10 | User createUser(User user); 11 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/ProductService.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service; 2 | 3 | 4 | import io.github.junhkang.springboottesting.domain.Product; 5 | 6 | import java.util.List; 7 | 8 | public interface ProductService { 9 | List getAllProducts(); 10 | Product getProductById(Long id); 11 | Product createProduct(Product product); 12 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/User.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.Data; 5 | 6 | @Entity 7 | @Table(name = "users") 8 | @Data 9 | public class User { 10 | @Id 11 | @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | private Long id; 13 | 14 | private String username; 15 | private String email; 16 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/SpringBootTestingFromZeroToHeroApplicationTests.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | import org.springframework.test.context.ActiveProfiles; 6 | 7 | @SpringBootTest 8 | @ActiveProfiles("mybatis") 9 | class SpringBootTestingFromZeroToHeroApplicationTests { 10 | @Test 11 | void contextLoads() { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/Product.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.Data; 5 | 6 | @Entity 7 | @Table(name = "product") 8 | @Data 9 | public class Product { 10 | @Id 11 | @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | private Long id; 13 | 14 | private String name; 15 | private String description; 16 | private Double price; 17 | private Integer stock; 18 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/mybatis/UserMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.mybatis; 2 | 3 | import io.github.junhkang.springboottesting.domain.UserDTO; 4 | import org.apache.ibatis.annotations.Mapper; 5 | 6 | import java.util.List; 7 | 8 | @Mapper 9 | public interface UserMapper { 10 | List findAll(); 11 | UserDTO findById(Long id); 12 | void insert(UserDTO user); 13 | void update(UserDTO user); 14 | void delete(Long id); 15 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/mybatis/ProductMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.mybatis; 2 | 3 | import io.github.junhkang.springboottesting.domain.ProductDTO; 4 | import org.apache.ibatis.annotations.Mapper; 5 | 6 | import java.util.List; 7 | 8 | @Mapper 9 | public interface ProductMapper { 10 | List findAll(); 11 | ProductDTO findById(Long id); 12 | void insert(ProductDTO product); 13 | void update(ProductDTO product); 14 | void delete(Long id); 15 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/SpringBootTestingFromZeroToHeroApplication.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | @SuppressWarnings("Unused") 8 | public class SpringBootTestingFromZeroToHeroApplication { 9 | 10 | public static void main(String[] args) { 11 | SpringApplication.run(SpringBootTestingFromZeroToHeroApplication.class, args); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/jpa/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.jpa; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import io.github.junhkang.springboottesting.domain.User; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | import java.time.LocalDateTime; 8 | import java.util.List; 9 | 10 | public interface OrderRepository extends JpaRepository { 11 | List findByUser(User user); 12 | List findByOrderDateBetween(LocalDateTime startDate, LocalDateTime endDate); 13 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/OrderDTO.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import lombok.Data; 4 | 5 | import java.time.LocalDateTime; 6 | 7 | @Data 8 | public class OrderDTO { 9 | private Long id; 10 | private LocalDateTime orderDate; 11 | private Long userId; 12 | private String username; 13 | private String userEmail; 14 | private Long productId; 15 | private String productName; 16 | private String productDescription; 17 | private Double productPrice; 18 | private Integer productStock; 19 | private Integer quantity; 20 | private String status; 21 | private Double totalAmount; 22 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/exception/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class ResourceNotFoundException extends RuntimeException { 8 | 9 | public ResourceNotFoundException() { 10 | super(); 11 | } 12 | 13 | public ResourceNotFoundException(String message) { 14 | super(message); 15 | } 16 | 17 | public ResourceNotFoundException(String message, Throwable cause) { 18 | super(message, cause); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/domain/Order.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.domain; 2 | 3 | import jakarta.persistence.*; 4 | import lombok.Data; 5 | import java.time.LocalDateTime; 6 | 7 | @Entity 8 | @Table(name = "orders") 9 | @Data 10 | public class Order { 11 | @Id 12 | @GeneratedValue(strategy = GenerationType.IDENTITY) 13 | private Long id; 14 | 15 | private LocalDateTime orderDate; 16 | 17 | @ManyToOne 18 | @JoinColumn(name = "user_id") 19 | private User user; 20 | 21 | @ManyToOne 22 | @JoinColumn(name = "product_id") 23 | private Product product; 24 | 25 | private Integer quantity; 26 | 27 | @Enumerated(EnumType.STRING) 28 | private OrderStatus status; 29 | 30 | private Double totalAmount; 31 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/OrderService.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import org.springframework.stereotype.Service; 5 | 6 | import java.time.LocalDateTime; 7 | import java.util.List; 8 | @Service 9 | public interface OrderService { 10 | List getAllOrders(); 11 | Order getOrderById(Long id); 12 | Order createOrder(Long userId, Long productId, Integer quantity); 13 | Order cancelOrder(Long id); 14 | Order updateOrderQuantity(Long id, Integer newQuantity); 15 | List getOrdersByUserId(Long userId); 16 | List getOrdersByDateRange(LocalDateTime startDate, LocalDateTime endDate); 17 | Double calculateTotalAmount(Long id); 18 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/repository/mybatis/OrderMapper.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.repository.mybatis; 2 | 3 | import io.github.junhkang.springboottesting.domain.OrderDTO; 4 | 5 | import org.apache.ibatis.annotations.Mapper; 6 | import org.apache.ibatis.annotations.Param; 7 | 8 | import java.time.LocalDateTime; 9 | import java.util.List; 10 | 11 | @Mapper 12 | public interface OrderMapper { 13 | List findAll(); 14 | OrderDTO findById(@Param("id") Long id); 15 | List findByUserId(@Param("userId") Long userId); 16 | List findByOrderDateBetween(@Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); 17 | void insert(OrderDTO order); 18 | void update(OrderDTO order); 19 | void delete(@Param("id") Long id); 20 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/exception/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.exception; 2 | 3 | import org.springframework.http.ResponseEntity; 4 | import org.springframework.web.bind.annotation.ExceptionHandler; 5 | import org.springframework.web.bind.annotation.RestControllerAdvice; 6 | 7 | @RestControllerAdvice 8 | public class GlobalExceptionHandler { 9 | 10 | @ExceptionHandler(ResourceNotFoundException.class) 11 | public ResponseEntity handleResourceNotFoundException(ResourceNotFoundException ex) { 12 | return ResponseEntity.status(404).body(ex.getMessage()); 13 | } 14 | 15 | @ExceptionHandler(IllegalArgumentException.class) 16 | public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex) { 17 | return ResponseEntity.badRequest().body(ex.getMessage()); 18 | } 19 | } -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Maven 빌드 디렉토리 2 | /target/ 3 | 4 | # Maven Wrapper 5 | .mvn/wrapper/maven-wrapper.jar 6 | !**/src/main/**/maven-wrapper.jar 7 | 8 | # Eclipse 설정 파일 9 | .classpath 10 | .project 11 | .settings/ 12 | # Eclipse Core 13 | .metadata 14 | # Eclipse Code Style 15 | .codestyle/ 16 | # Eclipse Preview 17 | # Spring Boot DevTools restart directory 18 | .spring-boot-devtools/ 19 | 20 | # IntelliJ IDEA 설정 파일 21 | *.iml 22 | *.ipr 23 | *.iws 24 | .idea/ 25 | 26 | # NetBeans 설정 파일 27 | /nbproject/private/ 28 | /build/ 29 | /nbbuild/ 30 | /dist/ 31 | /nbdist/ 32 | /.nb-gradle/ 33 | 34 | # VS Code 설정 파일 35 | .vscode/ 36 | 37 | # OS별 파일 38 | .DS_Store 39 | Thumbs.db 40 | *.swp 41 | *~ 42 | 43 | # 로그 파일 44 | *.log 45 | 46 | # 환경 설정 파일 47 | .env 48 | 49 | # 빌드 출력물 50 | /out/ 51 | /bin/ 52 | 53 | # 테스트 결과 54 | /test-results/ 55 | /surefire-reports/ 56 | 57 | # 기타 임시 파일 58 | .mvn/timing.properties 59 | pom.xml.tag 60 | pom.xml.releaseBackup 61 | pom.xml.versionsBackup 62 | pom.xml.next 63 | release.properties 64 | dependency-reduced-pom.xml 65 | buildNumber.properties 66 | HELP.md 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jun Kang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/controller/UserController.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.service.UserService; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.util.List; 9 | 10 | @RestController 11 | @RequestMapping("/users") 12 | public class UserController { 13 | private final UserService userService; 14 | 15 | public UserController(UserService userService) { 16 | this.userService = userService; 17 | } 18 | 19 | @GetMapping 20 | public List getAllUsers() { 21 | return userService.getAllUsers(); 22 | } 23 | 24 | @GetMapping("/{id}") 25 | public ResponseEntity getUserById(@PathVariable Long id) { 26 | User user = userService.getUserById(id); 27 | return ResponseEntity.ok(user); 28 | } 29 | 30 | @PostMapping 31 | public User createUser(@RequestBody User user) { 32 | return userService.createUser(user); 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/controller/ProductController.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import io.github.junhkang.springboottesting.service.ProductService; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.util.List; 9 | 10 | @RestController 11 | @RequestMapping("/products") 12 | public class ProductController { 13 | private final ProductService productService; 14 | 15 | public ProductController(ProductService productService) { 16 | this.productService = productService; 17 | } 18 | 19 | @GetMapping 20 | public List getAllProducts() { 21 | return productService.getAllProducts(); 22 | } 23 | 24 | @GetMapping("/{id}") 25 | public ResponseEntity getProductById(@PathVariable Long id) { 26 | Product product = productService.getProductById(id); 27 | return ResponseEntity.ok(product); 28 | } 29 | 30 | @PostMapping 31 | public Product createProduct(@RequestBody Product product) { 32 | return productService.createProduct(product); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /REFERENCES.md: -------------------------------------------------------------------------------- 1 | # References 2 | 3 | 1. [JUnit 5 User Guide](https://junit.org/junit5/docs/current/user-guide/) 4 | - Official documentation for JUnit 5, explaining core features and annotations. 5 | 2. [Mockito Documentation](https://site.mockito.org/) 6 | - Official Mockito documentation providing details on mocking frameworks. 7 | 3. [Applying Test-Driven Development](https://medium.com/pilar-2020/applying-test-driven-development-6d6d3af186cb) 8 | - An in-depth article explaining the TDD cycle and its application. 9 | 4. [Writing Human-Readable Tests: A Guide to Effective BDD Practices](https://medium.com/@dineshrajdhanapathy/writing-human-readable-tests-a-guide-to-effective-bdd-practices-75a7ab7888bb) 10 | - Guide on writing human-readable tests using Behavior-Driven Development (BDD). 11 | 5. [Mastering the Test Pyramid](https://www.headspin.io/blog/the-testing-pyramid-simplified-for-one-and-all) 12 | - Article explaining the testing pyramid and best practices for balancing unit, integration, and E2E tests. 13 | 6. [Spring 환경에 바로 적용하는 테스트의 모든것 초격차 패키지Online.](https://fastcampus.co.kr/dev_online_test) 14 | 7. https://www.linkedin.com/posts/danielmoka_mocks-are-one-of-the-most-misunderstood-and-activity-7241327748164571136-hGZn/?utm_source=share&utm_medium=member_ios -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # ===================================== 2 | # ??? ?? ?? (H2 In-Memory Database ??) 3 | # ===================================== 4 | 5 | spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE 6 | spring.datasource.driverClassName=org.h2.Driver 7 | spring.datasource.username=sa 8 | spring.datasource.password= 9 | 10 | # ===================================== 11 | # JPA ?? 12 | # ===================================== 13 | 14 | # Hibernate DDL ?? ??: create-drop (??? ?? ? ?????? ??) 15 | spring.jpa.hibernate.ddl-auto=create-drop 16 | 17 | # SQL ?? ??? 18 | spring.jpa.show-sql=true 19 | spring.jpa.properties.hibernate.format_sql=true 20 | 21 | # ===================================== 22 | # MyBatis ?? 23 | # ===================================== 24 | 25 | # MyBatis ?? XML ?? ?? 26 | mybatis.mapper-locations=classpath:mapper/*.xml 27 | 28 | # MyBatis TypeAliases ?? (??? ??? ??) 29 | mybatis.type-aliases-package=io.github.junhkang.springboottesting.domain 30 | mybatis.configuration.map-underscore-to-camel-case=true 31 | # ===================================== 32 | # H2 ?? ?? (?? ? ??? ?) 33 | # ===================================== 34 | 35 | spring.h2.console.enabled=true 36 | spring.h2.console.path=/h2-console 37 | 38 | # ===================================== 39 | # ??? ??? ?? 40 | # ===================================== 41 | 42 | spring.jpa.defer-datasource-initialization=true 43 | # ===================================== 44 | # ?? ?? (?? ??) 45 | # ===================================== 46 | 47 | logging.level.org.springframework=INFO 48 | logging.level.io.github.junhkang.springboottesting=DEBUG -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/exception/ResourceNotFoundExceptionTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.exception; 2 | 3 | import org.junit.jupiter.api.DisplayName; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static org.assertj.core.api.AssertionsForClassTypes.assertThat; 7 | 8 | class ResourceNotFoundExceptionTest { 9 | @Test 10 | @DisplayName("기본 생성자 테스트") 11 | void testDefaultConstructor() { 12 | // Given & When: 기본 생성자를 사용해 예외 생성 13 | ResourceNotFoundException exception = new ResourceNotFoundException(); 14 | 15 | // Then: 예외 메시지가 null인지 확인 16 | assertThat(exception.getMessage()).isNull(); 17 | } 18 | 19 | @Test 20 | @DisplayName("메시지를 포함하는 생성자 테스트") 21 | void testMessageConstructor() { 22 | // Given: 예외 메시지 23 | String message = "Resource not found"; 24 | 25 | // When: 메시지를 포함하는 생성자를 사용해 예외 생성 26 | ResourceNotFoundException exception = new ResourceNotFoundException(message); 27 | 28 | // Then: 예외 메시지가 설정되었는지 확인 29 | assertThat(exception.getMessage()).isEqualTo(message); 30 | } 31 | 32 | @Test 33 | @DisplayName("메시지와 원인(cause)을 포함하는 생성자 테스트") 34 | void testMessageAndCauseConstructor() { 35 | // Given: 예외 메시지와 원인 예외 36 | String message = "Resource not found"; 37 | Throwable cause = new RuntimeException("Cause of the exception"); 38 | 39 | // When: 메시지와 원인을 포함하는 생성자를 사용해 예외 생성 40 | ResourceNotFoundException exception = new ResourceNotFoundException(message, cause); 41 | 42 | // Then: 예외 메시지와 원인이 설정되었는지 확인 43 | assertThat(exception.getMessage()).isEqualTo(message); 44 | assertThat(exception.getCause()).isEqualTo(cause); 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/resources/mapper/UserMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 15 | 16 | 24 | 25 | 33 | 34 | 35 | INSERT INTO users (username, email) 36 | VALUES (#{username}, #{email}) 37 | 38 | 39 | 40 | UPDATE users 41 | SET 42 | username = #{username}, 43 | email = #{email} 44 | WHERE id = #{id} 45 | 46 | 47 | 48 | DELETE FROM users WHERE id = #{id} 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | -- users 테이블에 초기 사용자 데이터 삽입 2 | INSERT INTO users (username, email) VALUES 3 | ('john_doe', 'john.doe@example.com'), 4 | ('jane_smith', 'jane.smith@example.com'), 5 | ('alice_jones', 'alice.jones@example.com'); 6 | 7 | -- products 테이블에 초기 상품 데이터 삽입 8 | INSERT INTO product (name, description, price, stock) VALUES 9 | ('Laptop', 'High performance laptop', 1500.00, 10), 10 | ('Smartphone', 'Latest model smartphone', 800.00, 20), 11 | ('Headphones', 'Noise-cancelling headphones', 200.00, 15), 12 | ('Monitor', '4K Ultra HD monitor', 400.00, 8), 13 | ('Keyboard', 'Mechanical keyboard', 100.00, 25); 14 | 15 | -- orders 테이블에 초기 주문 데이터 삽입 16 | INSERT INTO orders (order_date, user_id, product_id, quantity, status, total_amount) VALUES 17 | ('2024-01-15 10:30:00', 1, 1, 2, 'PENDING', 3000.00), 18 | ('2024-02-20 14:45:00', 2, 3, 1, 'COMPLETED', 200.00), 19 | ('2024-03-05 09:15:00', 1, 2, 3, 'CANCELED', 2400.00), 20 | ('2024-04-10 16:00:00', 3, 4, 1, 'PENDING', 400.00), 21 | ('2024-05-25 11:20:00', 2, 5, 5, 'COMPLETED', 500.00); 22 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/JpaProductServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.jpa.ProductRepository; 6 | import io.github.junhkang.springboottesting.service.ProductService; 7 | import org.springframework.context.annotation.Profile; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | 12 | @Service 13 | @Profile("jpa") 14 | public class JpaProductServiceImpl implements ProductService { 15 | 16 | private final ProductRepository productRepository; 17 | 18 | public JpaProductServiceImpl(ProductRepository productRepository) { 19 | this.productRepository = productRepository; 20 | } 21 | 22 | @Override 23 | public List getAllProducts() { 24 | return productRepository.findAll(); 25 | } 26 | 27 | @Override 28 | public Product getProductById(Long id) { 29 | return productRepository.findById(id) 30 | .orElseThrow(() -> new ResourceNotFoundException("Product not found with id " + id)); 31 | } 32 | 33 | @Override 34 | public Product createProduct(Product product) { 35 | if (product.getName() == null || product.getName().trim().isEmpty()) { 36 | throw new IllegalArgumentException("Product name is required."); 37 | } 38 | if (product.getPrice() == null || product.getPrice() < 0) { 39 | throw new IllegalArgumentException("Product price cannot be negative."); 40 | } 41 | if (product.getStock() == null || product.getStock() < 0) { 42 | throw new IllegalArgumentException("Product stock cannot be negative."); 43 | } 44 | return productRepository.save(product); 45 | } 46 | } -------------------------------------------------------------------------------- /docs/1.WHY - 왜 테스트를 작성해야 하는가?.md: -------------------------------------------------------------------------------- 1 | # 1. WHY - 왜 테스트를 작성해야 하는가? 2 | 3 | ## 1.1 테스트 코드의 중요성 4 | 테스트 코드는 소프트웨어 개발에서 매우 중요한 역할을 합니다. 기능을 수정하거나 새로운 기능을 추가할 때 코드가 안정적으로 작동하는지 확인할 수 있는 수단이 바로 테스트 코드입니다. 이를 통해 예상하지 못한 버그를 방지하고, 코드 품질을 높일 수 있습니다. 5 | 6 | ## 1.2 테스트 코드 작성의 장점 7 | 8 | ### 1.2.1 안정적인 개발 환경 구축 9 | 테스트 코드는 코드의 변경이 다른 기능에 미치는 영향을 최소화하는 데 도움을 줍니다. 개발자는 자신 있게 코드를 수정하거나 리팩토링할 수 있으며, 기존 기능이 예상대로 작동하는지 검증할 수 있습니다. 10 | 11 | ### 1.2.2 버그 감소 및 코드 품질 향상 12 | 테스트 코드를 통해 코드 내 버그를 사전에 발견하고 해결할 수 있습니다. 이를 통해 운영 환경에서 발생할 수 있는 문제를 줄이고, 최종 사용자에게 더 나은 품질의 소프트웨어를 제공할 수 있습니다. 13 | 14 | ### 1.2.3 리팩토링의 용이성 15 | 테스트 코드가 있는 경우, 코드의 리팩토링을 안전하게 수행할 수 있습니다. 테스트는 코드 변경 후에도 기능이 정상적으로 작동하는지 확인해주므로, 리팩토링 과정에서 발생할 수 있는 예기치 않은 문제를 방지할 수 있습니다. 16 | 17 | ### 1.2.4 단일 책임 원칙(SOLID) 준수 18 | 테스트 코드를 작성하다 보면 자연스럽게 단일 책임 원칙(Single Responsibility Principle)을 준수하게 됩니다. 이는 각 클래스와 메서드가 하나의 책임만을 가지도록 하며, 유지보수가 용이한 코드를 작성하는 데 도움을 줍니다. 19 | 20 | ## 1.3 테스트를 작성하지 않았을 때의 문제점 21 | 22 | 테스트 코드가 없을 경우 다음과 같은 문제들이 발생할 수 있습니다: 23 | 24 | - **디버깅 시간 증가**: 코드에 문제가 발생했을 때 원인을 빠르게 파악하기 어렵습니다. 25 | - **리팩토링의 두려움**: 테스트가 없는 상태에서 리팩토링을 진행하면 기존 코드가 깨질 위험이 커집니다. 26 | - **기능 추가 시 불안정성**: 새로운 기능을 추가할 때 기존 기능이 정상적으로 동작하는지 확인할 수 없어 **기능 간 충돌**이 발생할 수 있습니다. 27 | - **유지보수의 어려움**: 시간이 지남에 따라 프로젝트의 복잡도가 증가하면, 테스트가 없는 시스템은 **유지보수 비용**이 급격히 증가합니다. 28 | 29 | ## 1.4 좋은 테스트 코드 - FIRST 원칙 30 | 좋은 테스트 코드는 다음의 **FIRST** 원칙을 준수해야 합니다: 31 | 32 | - **F - Fast (빠르게 실행되어야 함)**: 테스트는 빠르게 실행되어야 하며, 개발 중 자주 실행해도 부담이 없어야 합니다. 33 | - **I - Isolated (독립적으로 실행될 수 있어야 함)**: 각 테스트는 서로 의존하지 않고 독립적으로 실행될 수 있어야 합니다. 34 | - **R - Repeatable (반복 실행 가능해야 함)**: 테스트는 언제 실행하더라도 동일한 결과를 보장해야 합니다. 35 | - **S - Self-Validating (스스로 결과를 검증할 수 있어야 함)**: 테스트는 기대값과 실제값을 스스로 비교하여 성공 또는 실패 여부를 판단할 수 있어야 합니다. 36 | - **T - Timely (적시에 작성되어야 함)**: 테스트는 프로덕션 코드 작성 직전에 작성되어야 하며, TDD(Test-Driven Development) 방식과 잘 맞아떨어집니다. 37 | 38 | 이 원칙을 지키면, 코드의 품질과 테스트의 신뢰성을 높일 수 있습니다. 39 | 40 | --- 41 | 테스트 코드는 단순히 버그를 줄이는 역할을 넘어서, **개발 생산성을 높이고, 유지보수 비용을 줄이며, 리팩토링에 대한 자신감을 부여**하는 중요한 도구입니다. 적시에, 그리고 충분히 테스트를 작성하는 것은 **안정적이고 고품질의 소프트웨어 개발**을 가능하게 합니다. 42 | 43 | -------------------------------------------------------------------------------- /src/main/resources/mapper/ProductMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 17 | 18 | 28 | 29 | 39 | 40 | 41 | INSERT INTO product (name, description, price, stock) 42 | VALUES (#{name}, #{description}, #{price}, #{stock}) 43 | 44 | 45 | 46 | UPDATE product 47 | SET 48 | name = #{name}, 49 | description = #{description}, 50 | price = #{price}, 51 | stock = #{stock} 52 | WHERE id = #{id} 53 | 54 | 55 | 56 | DELETE FROM product WHERE id = #{id} 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/JpaUserServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.jpa.UserRepository; 6 | import io.github.junhkang.springboottesting.service.UserService; 7 | import org.springframework.context.annotation.Profile; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | import java.util.regex.Pattern; 12 | 13 | @Service 14 | @Profile("jpa") 15 | public class JpaUserServiceImpl implements UserService { 16 | 17 | private final UserRepository userRepository; 18 | private static final Pattern EMAIL_PATTERN = Pattern.compile( 19 | "^[A-Za-z0-9+_.-]+@(.+)$" 20 | ); 21 | public JpaUserServiceImpl(UserRepository userRepository) { 22 | this.userRepository = userRepository; 23 | } 24 | 25 | @Override 26 | public List getAllUsers() { 27 | return userRepository.findAll(); 28 | } 29 | 30 | @Override 31 | public User getUserById(Long id) { 32 | return userRepository.findById(id) 33 | .orElseThrow(() -> new ResourceNotFoundException("User not found with id " + id)); 34 | } 35 | 36 | @Override 37 | public User createUser(User user) { 38 | if (user.getUsername() == null || user.getUsername().trim().isEmpty()) { 39 | throw new IllegalArgumentException("Username is required."); 40 | } 41 | if (user.getEmail() == null || user.getEmail().trim().isEmpty()) { 42 | throw new IllegalArgumentException("Email is required."); 43 | } 44 | if (!isValidEmail(user.getEmail())) { 45 | throw new IllegalArgumentException("Invalid email format."); 46 | } 47 | return userRepository.save(user); 48 | } 49 | 50 | /** 51 | * 이메일 형식이 유효한지 검증하는 메서드 52 | * 53 | * @param email 검증할 이메일 문자열 54 | * @return 유효한 이메일 형식이면 true, 아니면 false 55 | */ 56 | private boolean isValidEmail(String email) { 57 | return EMAIL_PATTERN.matcher(email).matches(); 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/MyBatisUserServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.domain.UserDTO; 5 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 6 | import io.github.junhkang.springboottesting.repository.mybatis.UserMapper; 7 | import io.github.junhkang.springboottesting.service.UserService; 8 | import org.springframework.context.annotation.Profile; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.List; 12 | import java.util.stream.Collectors; 13 | 14 | @Service 15 | @Profile("mybatis") 16 | public class MyBatisUserServiceImpl implements UserService { 17 | private final UserMapper userMapper; 18 | 19 | public MyBatisUserServiceImpl(UserMapper userMapper) { 20 | this.userMapper = userMapper; 21 | } 22 | 23 | @Override 24 | public List getAllUsers() { 25 | return userMapper.findAll().stream() 26 | .map(dto -> { 27 | User user = new User(); 28 | user.setId(dto.getId()); 29 | user.setUsername(dto.getUsername()); 30 | user.setEmail(dto.getEmail()); 31 | return user; 32 | }) 33 | .collect(Collectors.toList()); 34 | } 35 | 36 | @Override 37 | public User getUserById(Long id) { 38 | UserDTO dto = userMapper.findById(id); 39 | if (dto == null) { 40 | throw new ResourceNotFoundException("User not found with id " + id); 41 | } 42 | User user = new User(); 43 | user.setId(dto.getId()); 44 | user.setUsername(dto.getUsername()); 45 | user.setEmail(dto.getEmail()); 46 | return user; 47 | } 48 | 49 | @Override 50 | public User createUser(User user) { 51 | if (user.getUsername() == null || user.getUsername().trim().isEmpty()) { 52 | throw new IllegalArgumentException("User name is required."); 53 | } 54 | if (user.getEmail() == null || user.getEmail().trim().isEmpty()) { 55 | throw new IllegalArgumentException("User email is required."); 56 | } 57 | 58 | UserDTO dto = new UserDTO(); 59 | dto.setUsername(user.getUsername()); 60 | dto.setEmail(user.getEmail()); 61 | 62 | userMapper.insert(dto); 63 | user.setId(dto.getId()); 64 | return user; 65 | } 66 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/exception/GlobalExceptionHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.exception; 2 | 3 | import io.github.junhkang.springboottesting.controller.UserController; 4 | import io.github.junhkang.springboottesting.domain.User; 5 | import io.github.junhkang.springboottesting.service.UserService; 6 | import org.junit.jupiter.api.DisplayName; 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.Mockito; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 11 | import org.springframework.boot.test.mock.mockito.MockBean; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import static org.mockito.ArgumentMatchers.anyLong; 15 | import static org.mockito.Mockito.when; 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 20 | 21 | 22 | @WebMvcTest(UserController.class) 23 | class GlobalExceptionHandlerTest { 24 | 25 | @Autowired 26 | private MockMvc mockMvc; 27 | 28 | @MockBean 29 | private UserService userService; 30 | 31 | @Test 32 | @DisplayName("ResourceNotFoundException 처리 테스트") 33 | void testHandleResourceNotFoundException() throws Exception { 34 | // Given: userService.getUserById가 ResourceNotFoundException을 던지도록 설정 35 | when(userService.getUserById(anyLong())).thenThrow(new ResourceNotFoundException("User not found")); 36 | 37 | // When & Then: 해당 URL로 GET 요청 시 404 상태 코드와 에러 메시지를 반환하는지 확인 38 | mockMvc.perform(get("/users/999")) 39 | .andExpect(status().isNotFound()) 40 | .andExpect(content().string("User not found")); 41 | } 42 | 43 | @Test 44 | @DisplayName("IllegalArgumentException 처리 테스트") 45 | void testHandleIllegalArgumentException() throws Exception { 46 | // Given: userService.createUser가 IllegalArgumentException을 던지도록 설정 47 | when(userService.getUserById(Mockito.any(Long.class))).thenThrow(new IllegalArgumentException("Invalid input")); 48 | 49 | // When & Then: 해당 URL로 POST 요청 시 400 상태 코드와 에러 메시지를 반환하는지 확인 50 | mockMvc.perform(get("/users/999")) // 적절한 요청 메서드로 변경 가능 51 | .andExpect(status().isBadRequest()) 52 | .andExpect(content().string("Invalid input")); 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/controller/OrderController.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import io.github.junhkang.springboottesting.service.OrderService; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.time.LocalDateTime; 9 | import java.util.List; 10 | 11 | 12 | @RestController 13 | @RequestMapping("/orders") 14 | public class OrderController { 15 | private final OrderService orderService; 16 | 17 | public OrderController(OrderService orderService) { 18 | this.orderService = orderService; 19 | } 20 | 21 | @GetMapping 22 | public List getAllOrders() { 23 | return orderService.getAllOrders(); 24 | } 25 | 26 | @GetMapping("/{id}") 27 | public ResponseEntity getOrderById(@PathVariable Long id) { 28 | Order order = orderService.getOrderById(id); 29 | return ResponseEntity.ok(order); 30 | } 31 | 32 | @PostMapping 33 | public Order createOrder(@RequestParam Long userId, @RequestParam Long productId, @RequestParam Integer quantity) { 34 | return orderService.createOrder(userId, productId, quantity); 35 | } 36 | 37 | @DeleteMapping("/{id}/cancel") 38 | public ResponseEntity cancelOrder(@PathVariable Long id) { 39 | Order canceledOrder = orderService.cancelOrder(id); 40 | return ResponseEntity.ok(canceledOrder); 41 | } 42 | 43 | @PutMapping("/{id}/quantity") 44 | public ResponseEntity updateOrderQuantity(@PathVariable Long id, @RequestParam Integer newQuantity) { 45 | Order updatedOrder = orderService.updateOrderQuantity(id, newQuantity); 46 | return ResponseEntity.ok(updatedOrder); 47 | } 48 | 49 | @GetMapping("/user/{userId}") 50 | public List getOrdersByUserId(@PathVariable Long userId) { 51 | return orderService.getOrdersByUserId(userId); 52 | } 53 | 54 | @GetMapping("/date") 55 | public List getOrdersByDateRange(@RequestParam String startDate, @RequestParam String endDate) { 56 | LocalDateTime start = LocalDateTime.parse(startDate); 57 | LocalDateTime end = LocalDateTime.parse(endDate); 58 | return orderService.getOrdersByDateRange(start, end); 59 | } 60 | 61 | @GetMapping("/{id}/totalAmount") 62 | public ResponseEntity calculateTotalAmount(@PathVariable Long id) { 63 | Double totalAmount = orderService.calculateTotalAmount(id); 64 | return ResponseEntity.ok(totalAmount); 65 | } 66 | } -------------------------------------------------------------------------------- /docs/4.HOW DEEP - 얼마나 깊게 테스트 코드를 작성해야 하는가?.md: -------------------------------------------------------------------------------- 1 | # 4. HOW DEEP - 얼마나 깊게 테스트 코드를 작성해야 하는가? 2 | 3 | ## 4.1 테스트 깊이를 결정하는 기준 4 | 5 | 테스트 깊이를 설정할 때는 다음과 같은 기준을 고려해야 합니다: 6 | 7 | - **테스트 피라미드(Test Pyramid)**: 테스트 피라미드는 테스트 종류에 따른 계층 구조를 보여줍니다. 일반적으로 단위 테스트가 가장 많고, 그다음으로 통합 테스트, 시스템 또는 E2E(End-to-End) 테스트가 위치합니다. 8 | - **단위 테스트(Unit Tests)**: 가장 많은 비중을 차지하며, 작은 코드 단위를 독립적으로 테스트합니다. 9 | - **통합 테스트(Integration Tests)**: 여러 모듈이 상호작용하는지 테스트합니다. 10 | - **E2E 테스트(End-to-End Tests)**: 실제 사용자 관점에서 전체 시스템이 잘 작동하는지 확인합니다. 11 | 12 | - **위험 기반 테스트(Risk-Based Testing)**: 비즈니스 중요도와 잠재적 위험 요소에 따라 테스트 우선순위를 설정합니다. 비즈니스에 중요한 기능이나 리스크가 높은 부분에 대한 테스트는 더 깊이 있게 수행합니다. 13 | 14 | - **유스 케이스 기반 테스트**: 핵심 사용자 흐름과 엣지 케이스를 기반으로 테스트를 작성합니다. 실제로 사용자가 자주 사용하는 기능이나 예외적인 상황에서의 동작을 검증하는 것이 중요합니다. 15 | 16 | - **현실적인 제약과 팀 역량 고려**: 모든 부분을 깊이 테스트하는 것은 시간과 리소스 측면에서 비효율적일 수 있습니다. 팀의 역량과 프로젝트 일정 등을 고려하여 테스트 깊이를 조정하는 것이 필요합니다. 17 | 18 | ![TEST PYRAMID](../img/test_pyramid.png) 19 | 20 | 이미지 출처: [Mastering the Test Pyramid](https://www.headspin.io/blog/the-testing-pyramid-simplified-for-one-and-all) 21 | 22 | ## 4.2 테스트 커버리지 및 품질 지표 활용 23 | 24 | - **코드 커버리지**: 코드 커버리지는 테스트가 소스 코드의 얼마나 많은 부분을 실행하는지를 나타내는 지표입니다. 일반적으로 라인 커버리지(Line Coverage)와 브랜치 커버리지(Branch Coverage)를 측정합니다. 25 | - **라인 커버리지(Line Coverage)**: 테스트가 실행된 코드 라인의 비율. 26 | - **브랜치 커버리지(Branch Coverage)**: 분기문(예: if/else)의 각 분기를 테스트했는지 확인하는 비율. 27 | - 다음은 본 리포지토리 샘플 소스의 테스트 커버리지입니다. 28 | 29 | ![TEST COVERAGE](../img/test_coverage.png) 30 | 31 | - **커버리지 목표 설정**: 높은 커버리지는 중요하지만, 무조건 100% 커버리지를 목표로 하는 것은 오히려 비효율적일 수 있습니다. 핵심 비즈니스 로직이나 복잡한 부분에 집중하여 테스트 깊이를 설정하는 것이 중요합니다. 32 | 33 | ## 4.3 오버테스팅의 문제점 34 | 35 | - **유지보수 비용 증가**: 불필요하게 많은 테스트는 유지보수 부담을 가중시킬 수 있습니다. 코드가 변경될 때마다 테스트도 함께 수정해야 할 수 있으며, 이는 오히려 생산성을 저해할 수 있습니다. 36 | - **개발 속도 저하**: 모든 기능을 테스트하려다 보면 개발 속도가 느려질 수 있습니다. 핵심적인 부분에 집중하는 것이 효율적입니다. 37 | - **리소스 낭비**: 지나치게 많은 테스트는 리소스를 낭비하게 만듭니다. 테스트를 효율적으로 유지하는 것이 중요합니다. 38 | 39 | ## 4.4 효율적인 테스트 범위 설정을 위한 체크리스트 40 | 41 | - **핵심 로직에 대한 집중적인 테스트**: 42 | - 주요 비즈니스 로직을 검증합니다. 43 | - 경계 값 및 예외 상황을 철저히 테스트합니다. 44 | 45 | - **주요 기능 및 사용자 시나리오 검증**: 46 | - 핵심 사용자 흐름을 테스트하여 애플리케이션의 주요 사용 시나리오가 올바르게 작동하는지 확인합니다. 47 | - 다양한 사용자 역할 및 권한에 따른 테스트를 수행합니다. 48 | 49 | - **에러 및 예외 처리에 대한 테스트**: 50 | - 오류 메시지 및 예외 처리 로직을 검증합니다. 51 | - 인증 및 권한 부여와 관련된 로직에 대한 테스트를 강화합니다. 52 | 53 | - **외부 시스템 및 통합 부분 테스트**: 54 | - API 연동 및 응답 처리에 대한 테스트를 포함합니다. 55 | - 데이터베이스 트랜잭션 일관성 검증 등을 고려합니다. 56 | 57 | ## 4.5 리팩토링과 테스트의 균형 잡기 58 | 59 | - **필요한 부분에 집중하여 테스트 작성**: 모든 부분을 테스트하기보다는 중요한 부분에 집중하여 테스트합니다. 60 | - **불필요하거나 중복된 테스트 코드 제거**: 리팩토링 과정에서 불필요하거나 중복된 테스트 코드는 제거해야 합니다. 61 | - **테스트 코드의 유지보수성과 가독성 확보**: 테스트 코드도 프로덕션 코드처럼 유지보수성과 가독성을 확보해야 합니다. 62 | 63 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/MyBatisProductServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | 4 | import io.github.junhkang.springboottesting.domain.Product; 5 | import io.github.junhkang.springboottesting.domain.ProductDTO; 6 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 7 | import io.github.junhkang.springboottesting.repository.mybatis.ProductMapper; 8 | import io.github.junhkang.springboottesting.service.ProductService; 9 | import org.springframework.context.annotation.Profile; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | @Service 16 | @Profile("mybatis") 17 | public class MyBatisProductServiceImpl implements ProductService { 18 | 19 | private final ProductMapper productMapper; 20 | 21 | public MyBatisProductServiceImpl(ProductMapper productMapper) { 22 | this.productMapper = productMapper; 23 | } 24 | 25 | @Override 26 | public List getAllProducts() { 27 | return productMapper.findAll().stream() 28 | .map(dto -> { 29 | Product product = new Product(); 30 | product.setId(dto.getId()); 31 | product.setName(dto.getName()); 32 | product.setDescription(dto.getDescription()); 33 | product.setPrice(dto.getPrice()); 34 | product.setStock(dto.getStock()); 35 | return product; 36 | }) 37 | .collect(Collectors.toList()); 38 | } 39 | 40 | @Override 41 | public Product getProductById(Long id) { 42 | ProductDTO dto = productMapper.findById(id); 43 | if (dto == null) { 44 | throw new ResourceNotFoundException("Product not found with id " + id); 45 | } 46 | Product product = new Product(); 47 | product.setId(dto.getId()); 48 | product.setName(dto.getName()); 49 | product.setDescription(dto.getDescription()); 50 | product.setPrice(dto.getPrice()); 51 | product.setStock(dto.getStock()); 52 | return product; 53 | } 54 | 55 | @Override 56 | public Product createProduct(Product product) { 57 | if (product.getName() == null || product.getName().trim().isEmpty()) { 58 | throw new IllegalArgumentException("Product name is required.");} 59 | ProductDTO dto = new ProductDTO(); 60 | dto.setName(product.getName()); 61 | dto.setDescription(product.getDescription()); 62 | dto.setPrice(product.getPrice()); 63 | dto.setStock(product.getStock()); 64 | productMapper.insert(dto); 65 | product.setId(dto.getId()); 66 | return product; 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /docs/3.WHEN - 언제 테스트 코드를 적용해야 하는가?.md: -------------------------------------------------------------------------------- 1 | # 3. WHEN - 언제 테스트 코드를 적용해야 하는가? 2 | 3 | ## 3.1 TDD와 BDD의 개념 및 적용 시점 4 | 5 | - **TDD (Test-Driven Development)**: TDD는 테스트를 먼저 작성하고, 그 테스트를 통과할 수 있는 최소한의 코드를 작성하며 개발을 진행하는 방법론입니다. TDD는 테스트를 통해 명확한 요구사항을 확인하고 코드 품질을 보장하는 방법으로 활용됩니다. 6 | - **레드-그린-리팩터 사이클**: TDD의 기본 개발 사이클은 `레드 단계` (실패하는 테스트 작성) → `그린 단계` (테스트를 통과하기 위한 코드 작성) → `리팩터 단계` (코드 정리 및 최적화)로 이루어집니다. 7 | - **적용 시점**: 새로운 기능을 개발하거나 기존 코드를 리팩토링할 때, TDD를 통해 코드의 안정성과 유지보수성을 높일 수 있습니다. 8 | 9 | ![TDD Cycle](../img/tdd_cycle.png) 10 | 이미지 출처: [Applying Test-Driven Development](https://medium.com/pilar-2020/applying-test-driven-development-6d6d3af186cb) 11 | 12 | - **BDD (Behavior-Driven Development)**: BDD는 사용자의 관점에서 시스템의 동작(Behavior)을 기술하고, 그에 맞는 테스트를 작성하여 개발을 진행하는 방법론입니다. BDD는 테스트를 통해 요구사항을 명확히 하고, 기능적인 동작을 검증합니다. 13 | - **Given-When-Then 패턴**: BDD의 테스트는 `Given` (어떤 상황이 주어졌을 때), `When` (어떤 동작이 수행되었을 때), `Then` (그 결과로 어떤 일이 발생해야 한다)의 패턴을 따릅니다. 14 | - **적용 시점**: 새로운 요구사항이 정의될 때, BDD를 통해 고객의 요구사항을 명확히 이해하고 구현할 수 있습니다. 15 | 16 | ![BDD Cycle](../img/bdd_cycle.png) 17 | 이미지 출처: [Writing Human-Readable Tests: A Guide to Effective BDD Practices](https://medium.com/@dineshrajdhanapathy/writing-human-readable-tests-a-guide-to-effective-bdd-practices-75a7ab7888bb) 18 | 19 | ## 3.2 기존 코드베이스에 테스트 추가하기 20 | 21 | - **레거시 코드베이스에 테스트 추가 전략**: 기존 프로젝트에 테스트 코드를 추가할 때는 우선순위를 정하고, 주요 기능이나 자주 변경되는 코드부터 테스트를 작성하는 것이 중요합니다. 레거시 코드베이스에 테스트를 추가할 때 다음과 같은 전략을 사용할 수 있습니다. 22 | - **핵심 비즈니스 로직에 집중**: 테스트 작성의 우선순위는 핵심 기능, 주요 비즈니스 로직에 집중해야 합니다. 23 | - **테스트 가능성 개선**: 레거시 코드가 테스트하기 어렵다면, 코드의 모듈화 또는 의존성 분리(Dependency Injection) 등을 통해 테스트 가능성을 높이는 작업이 필요합니다. 24 | - **리팩토링 후 테스트 작성**: 테스트를 추가하기 전에, 코드가 너무 복잡하거나 결합도가 높다면 리팩토링을 먼저 진행한 후 테스트를 작성하는 것이 좋습니다. 25 | 26 | ## 3.3 새로운 기능 개발 시 테스트 작성 시점 27 | 28 | - **프로덕션 코드보다 테스트 코드를 먼저 작성**: TDD 원칙에 따라 새로운 기능을 개발할 때, 테스트를 먼저 작성하고 그 테스트를 통과할 수 있는 최소한의 프로덕션 코드를 작성하는 방식입니다. 29 | - **테스트 우선 작성의 장점**: 테스트를 먼저 작성함으로써, 새로운 기능에 대한 요구사항을 명확히 정의하고, 코드 작성 전에 논리적인 오류를 미리 방지할 수 있습니다. 30 | - **테스트 코드 기반의 개발 흐름**: 테스트를 작성하고 그 결과에 따라 프로덕션 코드를 작성함으로써, 테스트 주도 개발 흐름을 유지할 수 있습니다. 31 | 32 | ## 3.4 리팩토링 시 테스트의 역할 33 | 34 | - **기존 기능의 안정성 확보**: 리팩토링은 코드의 동작을 변경하지 않고 구조를 개선하는 작업입니다. 이 과정에서 테스트 코드는 기존 기능이 올바르게 동작하는지를 검증하는 역할을 합니다. 35 | - **리팩토링 후에도 테스트가 통과하는지 확인**: 리팩토링 후 기존 테스트가 모두 통과한다면, 기존 기능에 이상이 없음을 보장할 수 있습니다. 36 | - **테스트가 없는 리팩토링은 위험**: 리팩토링 전에 반드시 충분한 테스트 커버리지를 확보해야 하며, 그렇지 않으면 리팩토링 과정에서 의도치 않은 버그가 발생할 수 있습니다. 37 | 38 | --- 39 | 40 | ## 3.5 테스트 작성의 우선순위와 체크리스트 41 | 42 | 테스트 작성 시 고려해야 할 우선순위와 체크리스트는 다음과 같습니다. 43 | 44 | - **핵심 로직 및 비즈니스 규칙**: 가장 중요한 비즈니스 로직에 대해 우선적으로 테스트를 작성해야 합니다. 주요 사용 사례, 경계값 처리, 예외 상황 등이 여기에 포함됩니다. 45 | - **에러 및 예외 처리**: 예외 상황에 대한 테스트를 포함해야 합니다. 예외가 제대로 처리되고, 사용자가 오류를 이해할 수 있도록 명확한 메시지가 제공되는지 확인해야 합니다. 46 | - **테스트 피라미드 접근**: 단위 테스트, 통합 테스트, 시스템 테스트 순으로 우선순위를 정하고, 테스트의 깊이와 범위를 결정해야 합니다. 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Boot Testing From Zero To Hero 2 | 3 | 스프링 부트에서 테스트 코드를 처음부터 고급까지 다루는 종합 가이드입니다. 4 | 5 | ## 소개 6 | 7 | 많은 좋은 개발자분들께서 이미 테스트의 중요성과 작성 방법에 대해 잘 정리하고 공유하고 있습니다. **[산업의 역군](https://www.sankun.com/) 개발팀**에서는 이러한 자료들을 참고하여 TDD(Test-Driven Development), BDD(Behavior-Driven Development) 방식으로 개발을 진행하기도 하지만, **기존 코드베이스에 테스트 코드를 작성할 때**는 여전히 많은 고민과 노력이 따릅니다. 8 | 9 | 테스트 코드를 작성할 시 개발팀원이 동일한 수준의 이해도를 가지고 있음에도 불구하고, **테스트의 깊이**나 **테스트의 방향**이 서로 다른 경우가 종종 발생합니다. 이러한 상황을 개선하고, 더 안정적인 개발 프로세스를 구축하고자 **어느 정도의 규칙을 정하고**, 개발팀이 **같은 생각을 하며 같은 방향을 보며 개발**할 수 있도록 이 레포지토리를 만들게 되었습니다. 또한 **신규 백엔드 개발자의 온보딩**을 지원하기 위한 자료로도 활용하고자 합니다. 10 | 11 | 특히, **테스트 전략을 최적화하고, 코드 품질을 높이며, 팀 내에서 일관된 테스트 기준**을 세우기 위해 레포지토리는 다음의 네 가지 핵심 질문에 대한 고민을 하고자 합니다. 12 | 13 | 1. **WHY** - 왜 테스트를 작성해야 하는가? 14 | 2. **HOW** - 테스트 코드를 어떻게 작성해야 하는가? 15 | 3. **WHEN** - 언제 테스트 코드를 적용해야 하는가? 16 | 4. **HOW MUCH** - 얼마나 깊게 테스트 코드를 작성해야 하는가? 17 | 18 | 이 가이드는 간단한 전자상거래 시스템을 예제로 활용해 다양한 테스트 케이스와 실제 코드를 통해, 이 질문들에 대한 실용적인 답변을 제시합니다. 19 | 20 | # 목차 21 | 22 | 1. [WHY - 왜 테스트를 작성해야 하는가?](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/docs/1.WHY%20-%20%EC%99%9C%20%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC%20%EC%9E%91%EC%84%B1%ED%95%B4%EC%95%BC%20%ED%95%98%EB%8A%94%EA%B0%80%3F.md) 23 | - 1.1 테스트 코드의 중요성 24 | - 1.2 테스트 코드 작성의 장점 25 | - 1.2.1 안정적인 개발 환경 구축 26 | - 1.2.2 버그 감소 및 코드 품질 향상 27 | - 1.2.3 리팩토링의 용이성 28 | - 1.2.4 단일 책임 원칙(SOLID) 준수 29 | - 1.3 테스트를 작성하지 않았을 때의 문제점 30 | - 1.4 좋은 테스트 코드 - FIRST 원칙 31 | 32 | 33 | 2. [HOW - 테스트 코드를 어떻게 작성해야 하는가?](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/docs/2.HOW%20-%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%EB%A5%BC%20%EC%96%B4%EB%96%BB%EA%B2%8C%20%EC%9E%91%EC%84%B1%ED%95%B4%EC%95%BC%20%ED%95%98%EB%8A%94%EA%B0%80%3F.md) 34 | - 2.1 테스트 케이스 선택 방법 35 | - 2.2 TDD (Test-Driven Development) 방법론 36 | - 2.3 다양한 테스트 종류와 계층 구조 이해 37 | - 2.4 JUnit5 활용 38 | - 2.5 Mockito와 같은 Mocking 프레임워크 사용 39 | - 2.6 다양한 테스트 어노테이션 및 도구 활용 40 | 41 | 42 | 3. [WHEN - 언제 테스트 코드를 적용해야 하는가?](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/docs/3.WHEN%20-%20%EC%96%B8%EC%A0%9C%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%EB%A5%BC%20%EC%A0%81%EC%9A%A9%ED%95%B4%EC%95%BC%20%ED%95%98%EB%8A%94%EA%B0%80%3F.md) 43 | - 3.1 TDD와 BDD의 개념 및 적용 시점 44 | - 3.2 기존 코드베이스에 테스트 추가하기 45 | - 3.3 새로운 기능 개발 시 테스트 작성 시점 46 | - 3.4 리팩토링 시 테스트의 역할 47 | - 3.5 테스트 작성의 우선순위와 체크리스트 48 | 49 | 50 | 4. [HOW DEEP - 얼마나 깊게 테스트 코드를 작성해야 하는가?](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/docs/4.HOW%20DEEP%20-%20%EC%96%BC%EB%A7%88%EB%82%98%20%EA%B9%8A%EA%B2%8C%20%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%BD%94%EB%93%9C%EB%A5%BC%20%EC%9E%91%EC%84%B1%ED%95%B4%EC%95%BC%20%ED%95%98%EB%8A%94%EA%B0%80%3F.md) 51 | - 4.1 테스트 깊이를 결정하는 기준 52 | - 4.2 테스트 커버리지 및 품질 지표 활용 53 | - 4.3 오버테스팅의 문제점 54 | - 4.4 효율적인 테스트 범위 설정을 위한 체크리스트 55 | - 4.5 리팩토링과 테스트의 균형 잡기 -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.3.3 9 | 10 | 11 | io.github.junhkang 12 | springboot-testing-from-zero-to-hero 13 | 0.0.1-SNAPSHOT 14 | SpringBoot Testing From Zero To Hero 15 | Demo project for Spring Boot 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 21 31 | 32 | 33 | 34 | org.springframework.boot 35 | spring-boot-starter 36 | 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-test 41 | test 42 | 43 | 44 | 45 | org.projectlombok 46 | lombok 47 | 1.18.30 48 | provided 49 | 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-starter-data-jpa 54 | 55 | 56 | 57 | 58 | org.mybatis.spring.boot 59 | mybatis-spring-boot-starter 60 | 3.0.2 61 | 62 | 63 | 64 | org.mybatis.spring.boot 65 | mybatis-spring-boot-starter-test 66 | 2.1.3 67 | test 68 | 69 | 70 | 71 | 72 | com.h2database 73 | h2 74 | runtime 75 | 76 | 77 | org.springframework.boot 78 | spring-boot-starter-web 79 | 80 | 81 | org.mybatis 82 | mybatis-spring 83 | 3.0.3 84 | 85 | 86 | 87 | 88 | 89 | 90 | org.springframework.boot 91 | spring-boot-maven-plugin 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/controller/ProductControllerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import io.github.junhkang.springboottesting.service.ProductService; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.mockito.Mockito; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import java.util.Collections; 15 | 16 | import static org.hamcrest.Matchers.is; 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.mockito.ArgumentMatchers.anyLong; 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 23 | 24 | 25 | @WebMvcTest(ProductController.class) 26 | @DisplayName("ProductController 테스트") 27 | class ProductControllerTest { 28 | 29 | @Autowired 30 | private MockMvc mockMvc; 31 | 32 | @MockBean 33 | private ProductService productService; 34 | 35 | @Test 36 | @DisplayName("모든 상품 조회 테스트") 37 | void testGetAllProducts() throws Exception { 38 | // Given: Mocking service layer 39 | Product product = new Product(); 40 | product.setId(1L); 41 | product.setName("Test Product"); 42 | Mockito.when(productService.getAllProducts()).thenReturn(Collections.singletonList(product)); 43 | 44 | // When & Then: GET 요청을 수행하고 응답을 검증 45 | mockMvc.perform(get("/products")) 46 | .andExpect(status().isOk()) 47 | .andExpect(jsonPath("$[0].id", is(1))) 48 | .andExpect(jsonPath("$[0].name", is("Test Product"))); 49 | } 50 | 51 | @Test 52 | @DisplayName("상품 ID로 상품 조회 테스트") 53 | void testGetProductById() throws Exception { 54 | // Given: Mocking service layer 55 | Product product = new Product(); 56 | product.setId(1L); 57 | product.setName("Test Product"); 58 | Mockito.when(productService.getProductById(anyLong())).thenReturn(product); 59 | 60 | // When & Then: GET 요청을 수행하고 응답을 검증 61 | mockMvc.perform(get("/products/1")) 62 | .andExpect(status().isOk()) 63 | .andExpect(jsonPath("$.id", is(1))) 64 | .andExpect(jsonPath("$.name", is("Test Product"))); 65 | } 66 | 67 | @Test 68 | @DisplayName("상품 생성 테스트") 69 | void testCreateProduct() throws Exception { 70 | // Given: Mocking service layer 71 | Product product = new Product(); 72 | product.setId(1L); 73 | product.setName("New Product"); 74 | Mockito.when(productService.createProduct(any(Product.class))).thenReturn(product); 75 | 76 | // When & Then: POST 요청을 수행하고 응답을 검증 77 | mockMvc.perform(post("/products") 78 | .contentType(MediaType.APPLICATION_JSON) 79 | .content("{\"name\": \"New Product\", \"description\": \"New Description\", \"price\": 100.0, \"stock\": 10}")) 80 | .andExpect(status().isOk()) 81 | .andExpect(jsonPath("$.id", is(1))) 82 | .andExpect(jsonPath("$.name", is("New Product"))); 83 | } 84 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/controller/UserControllerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.service.UserService; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.mockito.Mockito; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import java.util.Collections; 15 | 16 | import static org.hamcrest.Matchers.is; 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.mockito.ArgumentMatchers.anyLong; 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 23 | 24 | @WebMvcTest(UserController.class) 25 | @DisplayName("UserController 테스트") 26 | class UserControllerTest { 27 | 28 | @Autowired 29 | private MockMvc mockMvc; 30 | 31 | @MockBean 32 | private UserService userService; 33 | 34 | /** 35 | * 모든 사용자 조회 테스트 36 | */ 37 | @Test 38 | @DisplayName("모든 사용자 조회 테스트") 39 | void testGetAllUsers() throws Exception { 40 | // Given: Mocking the service layer 41 | User user = new User(); 42 | user.setId(1L); 43 | user.setUsername("test_user"); 44 | Mockito.when(userService.getAllUsers()).thenReturn(Collections.singletonList(user)); 45 | 46 | // When & Then: GET 요청을 수행하고 응답을 검증 47 | mockMvc.perform(get("/users")) 48 | .andExpect(status().isOk()) 49 | .andExpect(jsonPath("$[0].id", is(1))) 50 | .andExpect(jsonPath("$[0].username", is("test_user"))); 51 | } 52 | 53 | /** 54 | * 사용자 ID로 조회 테스트 55 | */ 56 | @Test 57 | @DisplayName("사용자 ID로 조회 테스트") 58 | void testGetUserById() throws Exception { 59 | // Given: Mocking the service layer 60 | User user = new User(); 61 | user.setId(1L); 62 | user.setUsername("test_user"); 63 | Mockito.when(userService.getUserById(anyLong())).thenReturn(user); 64 | 65 | // When & Then: GET 요청을 수행하고 응답을 검증 66 | mockMvc.perform(get("/users/1")) 67 | .andExpect(status().isOk()) 68 | .andExpect(jsonPath("$.id", is(1))) 69 | .andExpect(jsonPath("$.username", is("test_user"))); 70 | } 71 | 72 | /** 73 | * 사용자 생성 테스트 74 | */ 75 | @Test 76 | @DisplayName("사용자 생성 테스트") 77 | void testCreateUser() throws Exception { 78 | // Given: Mocking the service layer 79 | User user = new User(); 80 | user.setId(1L); 81 | user.setUsername("new_user"); 82 | Mockito.when(userService.createUser(any(User.class))).thenReturn(user); 83 | 84 | // When & Then: POST 요청을 수행하고 응답을 검증 85 | mockMvc.perform(post("/users") 86 | .contentType(MediaType.APPLICATION_JSON) 87 | .content("{\"username\": \"new_user\", \"email\": \"new_user@example.com\"}")) 88 | .andExpect(status().isOk()) 89 | .andExpect(jsonPath("$.id", is(1))) 90 | .andExpect(jsonPath("$.username", is("new_user"))); 91 | } 92 | } -------------------------------------------------------------------------------- /src/main/resources/mapper/OrderMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 27 | 28 | 48 | 49 | 69 | 70 | 90 | 91 | 92 | INSERT INTO orders (order_date, user_id, product_id, quantity, status, total_amount) 93 | VALUES (#{orderDate}, #{userId}, #{productId}, #{quantity}, #{status}, #{totalAmount}) 94 | 95 | 96 | 97 | UPDATE orders 98 | SET 99 | order_date = #{orderDate}, 100 | user_id = #{userId}, 101 | product_id = #{productId}, 102 | quantity = #{quantity}, 103 | status = #{status}, 104 | total_amount = #{totalAmount} 105 | WHERE id = #{id} 106 | 107 | 108 | 109 | DELETE FROM orders WHERE id = #{id} 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /docs/2.HOW - 테스트 코드를 어떻게 작성해야 하는가?.md: -------------------------------------------------------------------------------- 1 | # 2. HOW - 테스트 코드를 어떻게 작성해야 하는가? 2 | 3 | ## 2.1 테스트 케이스 선택 방법 4 | 5 | - **첫 번째 테스트의 중요성**: 구현하기 가장 쉬운 테스트부터 시작하는 것이 좋습니다. 예외적인 상황이나 가장 빠르게 개발할 수 있는 테스트 케이스를 먼저 작성하고, 점차 확장해 나갑니다. 6 | - **점진적 확장**: 쉬운 테스트부터 시작해 점차 복잡한 테스트로 나아가면서 시스템의 안정성을 검증합니다. 7 | 8 | ## 2.2 TDD (Test-Driven Development) 방법론 9 | 10 | TDD는 테스트 주도 개발 방식으로, 테스트 코드를 먼저 작성하고 이를 기반으로 프로덕션 코드를 작성하는 방식입니다. TDD는 다음과 같은 세 단계를 따릅니다: 11 | 12 | 1. **레드 단계**: 실패하는 테스트를 작성합니다. 이때, 아직 프로덕션 코드는 작성되지 않았기 때문에 테스트는 실패합니다. 13 | 2. **그린 단계**: 최소한의 코드로 테스트를 통과시킵니다. 테스트를 성공시키기 위한 코드만 작성하여 빠르게 테스트를 통과합니다. 14 | 3. **리팩터 단계**: 중복을 제거하고 코드 구조를 개선합니다. 테스트가 통과한 후 코드의 가독성이나 유지보수성을 높이기 위해 리팩터링을 진행합니다. 15 | 16 | 이 과정을 반복하면서 점진적으로 시스템을 구축하고 테스트의 커버리지를 높여갑니다. 17 | 18 | ## 2.3 다양한 테스트 종류와 계층 구조 이해 19 | 20 | - **단위 테스트**: 개별 모듈이나 메서드를 테스트하는 방식으로, 가장 기본적인 테스트 방법입니다. 빠르고, 독립적으로 실행될 수 있습니다. 21 | - **통합 테스트**: 여러 모듈이 함께 작동하는지를 테스트하는 방식입니다. 단위 테스트보다 더 복잡한 시나리오를 검증할 수 있습니다. 22 | - **시스템 테스트**: 전체 시스템이 의도한 대로 작동하는지를 검증하는 테스트로, 사용자의 관점에서 테스트를 진행합니다. 23 | 24 | ## 2.4 JUnit5 활용 25 | 26 | JUnit5는 자바 테스트를 위한 표준 프레임워크입니다. 다음과 같은 주요 구성 요소와 기능을 갖추고 있습니다: 27 | 28 | - **주요 어노테이션** 29 | - `@Test`: 테스트 메서드를 나타냅니다. 30 | - `@BeforeAll`, `@BeforeEach`: 각각 전체 테스트 전, 개별 테스트 전 실행될 메서드를 정의합니다. 31 | - `@AfterEach`, `@AfterAll`: 각각 개별 테스트 후, 전체 테스트 후 실행될 메서드를 정의합니다. 32 | - **주요 어서션 메서드** 33 | - `assertEquals(expected, actual)`: 기대값과 실제값이 일치하는지 확인합니다. 34 | - `assertNull(object)`: 객체가 null인지 검증합니다. 35 | - `assertThrows()`: 예외가 발생하는지 검증합니다. 36 | 37 | 참조 코드: [JUnit 기본 테스트 예시 - UserServiceImplTest](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaUserServiceImplTest.java) 38 | 39 | ## 2.5 Mockito와 같은 Mocking 프레임워크 사용 40 | 41 | Mocking은 외부 의존성을 모방하여 테스트하는 방법입니다. `Mockito`와 같은 프레임워크를 사용하여 쉽게 Mock 객체를 생성하고 테스트할 수 있습니다. 42 | 43 | - **테스트 더블의 종류** 44 | - **Dummy**: 사용되지 않는 매개변수에 전달되는 객체입니다. 45 | - **Stub**: 미리 정의된 결과를 반환하는 객체입니다. 46 | - **Mock**: 동작을 검증할 수 있는 객체로, 메서드 호출 여부 등을 검증합니다. 47 | - **Spy**: 실제 객체의 동작을 일부 모니터링하거나 수정하는 객체입니다. 48 | - **Fake**: 실제 동작을 구현하지만, 단순하게 동작하는 테스트 객체입니다. 49 | 50 | 참조 코드: [Mockito를 활용한 UserControllerTest에서 Mock 객체 활용 예시](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/controller/UserControllerTest.java) 51 | 52 | ![5 test doubles](../img/5_test_doubles.png) 53 | 이미지 출처: [linkedin : Daniel Moka](https://www.linkedin.com/posts/danielmoka_mocks-are-one-of-the-most-misunderstood-and-activity-7241327748164571136-hGZn/?utm_source=share&utm_medium=member_ios) 54 | 55 | ## 2.6 다양한 테스트 어노테이션 및 도구 활용 56 | 57 | - **@ParameterizedTest**: 여러 파라미터를 전달하여 같은 테스트를 반복 실행합니다. [참조코드](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java#L103) 58 | - `@ValueSource`: 다양한 값을 전달합니다. 59 | - `@EnumSource`: Enum 타입의 파라미터를 전달합니다. 60 | - `@MethodSource`: 메서드를 통해 테스트 데이터를 제공합니다. 61 | - **@Nested**: 테스트를 그룹화하여 계층적으로 관리할 수 있습니다. [참조코드](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java#L81) 62 | - **Nested**를 사용하면 테스트를 논리적인 그룹으로 분리할 수 있으며, 이를 통해 **특정 도메인, 기능, 시나리오**에 대한 테스트를 더욱 체계적으로 관리할 수 있습니다. 특히, 여러 테스트 메서드를 계층적으로 정리하여 가독성을 높이고, 테스트 목적이 더 명확해지도록 도와줍니다. 63 | - **@DisplayName**: 테스트의 설명을 추가하여 가독성을 높일 수 있습니다. [참조코드](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java#L82) 64 | - **DisplayName**을 통해 각 테스트 메서드에 대해 직관적인 설명을 부여할 수 있으며, 이는 테스트 결과 리포트에서도 그대로 반영되어 가독성을 크게 향상시킵니다. **테스트 트리**를 시각화할 때 각 테스트의 목적과 역할을 쉽게 이해할 수 있도록 도와줍니다. 65 | - **@Timeout**: 테스트 실행 시간에 제한을 두어, 성능을 테스트할 수 있습니다. [참조코드](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java#L236) 66 | - **@RepeatedTest**: 동일한 테스트를 여러 번 반복 실행합니다. [참조코드](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java#L357) 67 | - **RepeatedTest**는 동일한 테스트를 여러 번 반복 실행하여, 특정 코드가 여러 실행 환경에서도 일관되게 동작하는지 확인하는 데 유용합니다. **성능 테스트**나 **동시성 이슈**를 확인할 때 자주 사용됩니다. 68 | - **@SpringBootTest**: 전체 스프링 컨텍스트를 로드하여 통합 테스트를 수행합니다. 69 | - **Testcontainers**: 컨테이너 환경을 활용하여 통합 테스트를 진행할 수 있습니다. 70 | 71 | ### 가독성 향상된 테스트 트리 예시 72 | 73 | `@Nested`와 `@DisplayName`을 적절히 사용하면 아래와 같이 **가독성이 높은 테스트 트리**를 구성할 수 있습니다: 74 | 75 | ![Nested Test Case Package](../img/nested_testcase_package.png) 76 | 77 | 참조 코드: [SpringBootTest와 다양한 어노테이션 활용 예시 - OrderControllerTest](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java) 78 | 79 | ## 2.7 MockMvc와 WebTestClient를 사용한 웹 레이어 테스트 80 | 81 | 웹 레이어 테스트를 위해 `MockMvc`와 `WebTestClient`를 사용합니다. 82 | 83 | - **MockMvc**: Spring MVC를 모킹하여 웹 애플리케이션의 HTTP 요청 및 응답을 테스트합니다. 84 | - **WebTestClient**: WebFlux를 지원하는 비동기식 테스트 클라이언트로, 웹 애플리케이션의 반응형 동작을 검증할 수 있습니다. 85 | 86 | 참조 코드: [MockMvc를 사용한 Web Layer 테스트 예시 - UserControllerTest](https://github.com/junhkang/springboot-testing-from-zero-to-hero/blob/main/src/test/java/io/github/junhkang/springboottesting/controller/UserControllerTest.java) -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import io.github.junhkang.springboottesting.domain.OrderStatus; 5 | import io.github.junhkang.springboottesting.domain.Product; 6 | import io.github.junhkang.springboottesting.domain.User; 7 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 8 | import io.github.junhkang.springboottesting.repository.jpa.OrderRepository; 9 | import io.github.junhkang.springboottesting.repository.jpa.ProductRepository; 10 | import io.github.junhkang.springboottesting.repository.jpa.UserRepository; 11 | import io.github.junhkang.springboottesting.service.OrderService; 12 | import org.springframework.context.annotation.Profile; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | import java.time.LocalDateTime; 17 | import java.util.List; 18 | 19 | 20 | @Service 21 | @Profile("jpa") 22 | public class JpaOrderServiceImpl implements OrderService { 23 | 24 | private final OrderRepository orderRepository; 25 | private final UserRepository userRepository; 26 | private final ProductRepository productRepository; 27 | 28 | public JpaOrderServiceImpl(OrderRepository orderRepository, UserRepository userRepository, ProductRepository productRepository) { 29 | this.orderRepository = orderRepository; 30 | this.userRepository = userRepository; 31 | this.productRepository = productRepository; 32 | } 33 | 34 | @Override 35 | public List getAllOrders() { 36 | return orderRepository.findAll(); 37 | } 38 | 39 | @Override 40 | public Order getOrderById(Long id) { 41 | return orderRepository.findById(id) 42 | .orElseThrow(() -> new ResourceNotFoundException("Order not found with id " + id)); 43 | } 44 | 45 | @Override 46 | @Transactional 47 | public Order createOrder(Long userId, Long productId, Integer quantity) { 48 | User user = userRepository.findById(userId) 49 | .orElseThrow(() -> new ResourceNotFoundException("User not found with id " + userId)); 50 | 51 | Product product = productRepository.findById(productId) 52 | .orElseThrow(() -> new ResourceNotFoundException("Product not found with id " + productId)); 53 | 54 | if (product.getStock() < quantity) { 55 | throw new IllegalArgumentException("Insufficient stock for product id " + productId); 56 | } 57 | 58 | product.setStock(product.getStock() - quantity); 59 | productRepository.save(product); 60 | 61 | Order order = new Order(); 62 | order.setOrderDate(LocalDateTime.now()); 63 | order.setUser(user); 64 | order.setProduct(product); 65 | order.setQuantity(quantity); 66 | order.setStatus(OrderStatus.PENDING); 67 | order.setTotalAmount(product.getPrice() * quantity); 68 | 69 | return orderRepository.save(order); 70 | } 71 | 72 | @Override 73 | @Transactional 74 | public Order cancelOrder(Long id) { 75 | Order order = getOrderById(id); 76 | 77 | if (order.getStatus() != OrderStatus.PENDING) { 78 | throw new IllegalArgumentException("Only pending orders can be canceled."); 79 | } 80 | 81 | order.setStatus(OrderStatus.CANCELED); 82 | orderRepository.save(order); 83 | 84 | // 재고 복구 85 | Product product = order.getProduct(); 86 | product.setStock(product.getStock() + order.getQuantity()); 87 | productRepository.save(product); 88 | 89 | return order; 90 | } 91 | 92 | @Override 93 | @Transactional 94 | public Order updateOrderQuantity(Long id, Integer newQuantity) { 95 | Order order = getOrderById(id); 96 | 97 | if (order.getStatus() != OrderStatus.PENDING) { 98 | throw new IllegalArgumentException("Only pending orders can be updated."); 99 | } 100 | 101 | Product product = order.getProduct(); 102 | int difference = newQuantity - order.getQuantity(); 103 | 104 | if (difference > 0 && product.getStock() < difference) { 105 | throw new IllegalArgumentException("Insufficient stock to increase quantity."); 106 | } 107 | 108 | product.setStock(product.getStock() - difference); 109 | productRepository.save(product); 110 | 111 | order.setQuantity(newQuantity); 112 | order.setTotalAmount(product.getPrice() * newQuantity); 113 | return orderRepository.save(order); 114 | } 115 | 116 | @Override 117 | public List getOrdersByUserId(Long userId) { 118 | User user = userRepository.findById(userId) 119 | .orElseThrow(() -> new ResourceNotFoundException("User not found with id " + userId)); 120 | 121 | return orderRepository.findByUser(user); 122 | } 123 | 124 | @Override 125 | public List getOrdersByDateRange(LocalDateTime startDate, LocalDateTime endDate) { 126 | return orderRepository.findByOrderDateBetween(startDate, endDate); 127 | } 128 | 129 | @Override 130 | public Double calculateTotalAmount(Long id) { 131 | Order order = getOrderById(id); 132 | return order.getTotalAmount(); 133 | } 134 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/MyBatisProductServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import io.github.junhkang.springboottesting.domain.ProductDTO; 5 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 6 | import io.github.junhkang.springboottesting.repository.mybatis.ProductMapper; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Nested; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.context.annotation.Import; 14 | import org.springframework.test.context.ActiveProfiles; 15 | 16 | import java.util.List; 17 | 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | import static org.junit.jupiter.api.Assertions.assertThrows; 20 | 21 | /** 22 | * MyBatisProductServiceImpl의 단위 테스트 클래스 23 | * MyBatis 기반의 ProductService 구현체를 테스트하며, 각 메서드의 동작을 검증합니다. 24 | */ 25 | @SpringBootTest 26 | @Import(MyBatisProductServiceImpl.class) 27 | @ActiveProfiles("mybatis") 28 | @DisplayName("MyBatisProductServiceImpl Test") 29 | class MyBatisProductServiceImplTest { 30 | 31 | @Autowired 32 | private ProductMapper productMapper; 33 | 34 | @Autowired 35 | private MyBatisProductServiceImpl productService; 36 | 37 | private Product testProduct; 38 | 39 | @BeforeEach 40 | void setUp() { 41 | // 테스트용 기본 상품 데이터 생성 및 저장 42 | testProduct = new Product(); 43 | testProduct.setName("Test Product"); 44 | testProduct.setDescription("Test Description"); 45 | testProduct.setPrice(100.0); 46 | testProduct.setStock(50); 47 | 48 | ProductDTO dto = new ProductDTO(); 49 | dto.setName(testProduct.getName()); 50 | dto.setDescription(testProduct.getDescription()); 51 | dto.setPrice(testProduct.getPrice()); 52 | dto.setStock(testProduct.getStock()); 53 | 54 | productMapper.insert(dto); // ProductMapper를 통해 데이터베이스에 저장 55 | testProduct.setId(dto.getId()); // 저장 후 ID 할당 56 | } 57 | 58 | @Nested 59 | @DisplayName("조회 관련 테스트") 60 | class RetrievalTests { 61 | 62 | @Test 63 | @DisplayName("모든 상품 조회 테스트") 64 | void testGetAllProducts() { 65 | // When: 모든 상품을 조회 66 | List products = productService.getAllProducts(); 67 | 68 | // Then: 데이터베이스에 저장된 상품이 정상적으로 조회되는지 검증 69 | assertThat(products).isNotNull(); 70 | assertThat(products.size()).isGreaterThan(0); // 최소 1개 이상 있어야 함 (테스트에서 생성한 상품 포함) 71 | } 72 | 73 | @Test 74 | @DisplayName("상품 ID로 상품 조회 테스트 - 존재하는 ID") 75 | void testGetProductByIdExists() { 76 | // When: 존재하는 상품 ID로 조회 77 | Product product = productService.getProductById(testProduct.getId()); 78 | 79 | // Then: 조회된 상품이 정상적으로 존재하며, 값이 정확한지 검증 80 | assertThat(product).isNotNull(); 81 | assertThat(product.getId()).isEqualTo(testProduct.getId()); 82 | assertThat(product.getName()).isEqualTo(testProduct.getName()); 83 | } 84 | 85 | @Test 86 | @DisplayName("상품 ID로 상품 조회 테스트 - 존재하지 않는 ID") 87 | void testGetProductByIdNotExists() { 88 | // Given: 존재하지 않는 상품 ID 89 | Long nonExistentId = 999L; 90 | 91 | // When & Then: 조회 시 ResourceNotFoundException이 발생하는지 검증 92 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 93 | productService.getProductById(nonExistentId); 94 | }); 95 | 96 | assertThat(exception.getMessage()).isEqualTo("Product not found with id " + nonExistentId); 97 | } 98 | } 99 | 100 | @Nested 101 | @DisplayName("생성 관련 테스트") 102 | class CreationTests { 103 | 104 | @Test 105 | @DisplayName("상품 생성 테스트") 106 | void testCreateProduct() { 107 | // Given: 새로운 상품 생성 108 | Product newProduct = new Product(); 109 | newProduct.setName("New Product"); 110 | newProduct.setDescription("New Description"); 111 | newProduct.setPrice(200.0); 112 | newProduct.setStock(30); 113 | 114 | // When: 상품 생성 115 | Product createdProduct = productService.createProduct(newProduct); 116 | 117 | // Then: 상품이 정상적으로 생성되고, 데이터베이스에 저장되었는지 검증 118 | assertThat(createdProduct).isNotNull(); 119 | assertThat(createdProduct.getId()).isNotNull(); 120 | assertThat(createdProduct.getName()).isEqualTo("New Product"); 121 | 122 | // Then: 데이터베이스에서 전체 상품 조회하여 상품이 추가되었는지 검증 123 | List products = productService.getAllProducts(); 124 | assertThat(products).hasSizeGreaterThan(1); // 기존 + 새로 생성한 상품 125 | } 126 | 127 | @Test 128 | @DisplayName("상품 생성 테스트 - 필수 필드 누락") 129 | void testCreateProductMissingFields() { 130 | // Given: 필수 필드(이름)이 누락된 상품 생성 131 | Product incompleteProduct = new Product(); 132 | incompleteProduct.setDescription("Missing Name"); 133 | incompleteProduct.setPrice(150.0); 134 | incompleteProduct.setStock(10); 135 | 136 | // When & Then: 상품 생성 시 필드 누락으로 예외 발생 검증 (Optional 검증) 137 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 138 | productService.createProduct(incompleteProduct); 139 | }); 140 | 141 | assertThat(exception.getMessage()).contains("Product name is required"); 142 | } 143 | } 144 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/MyBatisUserServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.domain.UserDTO; 5 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 6 | import io.github.junhkang.springboottesting.repository.mybatis.UserMapper; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Nested; 10 | import org.junit.jupiter.api.Test; 11 | import org.mybatis.spring.boot.test.autoconfigure.MybatisTest; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.context.annotation.Import; 15 | import org.springframework.test.context.ActiveProfiles; 16 | 17 | import java.util.List; 18 | 19 | import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 20 | import static org.junit.jupiter.api.Assertions.assertThrows; 21 | 22 | @SpringBootTest 23 | @Import(MyBatisUserServiceImpl.class) 24 | @ActiveProfiles("mybatis") 25 | @DisplayName("MyBatisUserServiceImpl Test") 26 | class MyBatisUserServiceImplTest { 27 | 28 | @Autowired 29 | private UserMapper userMapper; 30 | 31 | @Autowired 32 | private MyBatisUserServiceImpl userService; 33 | 34 | private User testUser; 35 | 36 | @BeforeEach 37 | void setUp() { 38 | // Given: 테스트에 사용할 사용자 생성 및 저장 39 | testUser = new User(); 40 | testUser.setUsername("test_user"); 41 | testUser.setEmail("test.user@example.com"); 42 | 43 | UserDTO userDTO = new UserDTO(); 44 | userDTO.setUsername(testUser.getUsername()); 45 | userDTO.setEmail(testUser.getEmail()); 46 | userMapper.insert(userDTO); 47 | testUser.setId(userDTO.getId()); 48 | } 49 | 50 | @Nested 51 | @DisplayName("사용자 조회 테스트") 52 | class UserRetrievalTests { 53 | 54 | @Test 55 | @DisplayName("모든 사용자 조회 테스트") 56 | void testGetAllUsers() { 57 | // Given: data.sql에서 미리 생성된 사용자들 58 | 59 | // When: 모든 사용자를 조회 60 | List users = userService.getAllUsers(); 61 | 62 | // Then: 데이터베이스에 저장된 총 사용자 수가 예상과 일치하는지 검증 63 | assertThat(users).isNotEmpty(); 64 | assertThat(users).anyMatch(user -> "test_user".equals(user.getUsername())); 65 | } 66 | 67 | @Test 68 | @DisplayName("사용자 ID로 사용자 조회 - 존재하는 ID") 69 | void testGetUserByIdExists() { 70 | // When: 존재하는 사용자 ID로 사용자 조회 71 | User foundUser = userService.getUserById(testUser.getId()); 72 | 73 | // Then: 조회된 사용자가 정상적으로 반환되었는지 검증 74 | assertThat(foundUser).isNotNull(); 75 | assertThat(foundUser.getId()).isEqualTo(testUser.getId()); 76 | assertThat(foundUser.getUsername()).isEqualTo(testUser.getUsername()); 77 | assertThat(foundUser.getEmail()).isEqualTo(testUser.getEmail()); 78 | } 79 | 80 | @Test 81 | @DisplayName("사용자 ID로 사용자 조회 - 존재하지 않는 ID") 82 | void testGetUserByIdNotExists() { 83 | // Given: 존재하지 않는 사용자 ID 84 | Long nonExistentId = 999L; 85 | 86 | // When & Then: 조회 시 ResourceNotFoundException이 발생하는지 검증 87 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 88 | userService.getUserById(nonExistentId); 89 | }); 90 | 91 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentId); 92 | } 93 | } 94 | 95 | @Nested 96 | @DisplayName("사용자 생성 테스트") 97 | class UserCreationTests { 98 | 99 | @Test 100 | @DisplayName("사용자 생성 테스트 - 성공 케이스") 101 | void testCreateUserSuccess() { 102 | // Given: 새로운 사용자 정보 103 | User newUser = new User(); 104 | newUser.setUsername("new_user"); 105 | newUser.setEmail("new.user@example.com"); 106 | 107 | // When: 사용자를 생성 108 | User createdUser = userService.createUser(newUser); 109 | 110 | // Then: 생성된 사용자가 정상적으로 저장되었는지 검증 111 | assertThat(createdUser).isNotNull(); 112 | assertThat(createdUser.getId()).isNotNull(); 113 | assertThat(createdUser.getUsername()).isEqualTo("new_user"); 114 | assertThat(createdUser.getEmail()).isEqualTo("new.user@example.com"); 115 | } 116 | 117 | @Test 118 | @DisplayName("사용자 생성 테스트 - 필수 필드 누락 (이름)") 119 | void testCreateUserMissingName() { 120 | // Given: 이름이 누락된 사용자 121 | User incompleteUser = new User(); 122 | incompleteUser.setEmail("missing.name@example.com"); 123 | 124 | // When & Then: 생성 시 IllegalArgumentException이 발생하는지 검증 125 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 126 | userService.createUser(incompleteUser); 127 | }); 128 | 129 | assertThat(exception.getMessage()).isEqualTo("User name is required."); 130 | } 131 | 132 | @Test 133 | @DisplayName("사용자 생성 테스트 - 필수 필드 누락 (이메일)") 134 | void testCreateUserMissingEmail() { 135 | // Given: 이메일이 누락된 사용자 136 | User incompleteUser = new User(); 137 | incompleteUser.setUsername("missing_email"); 138 | 139 | // When & Then: 생성 시 IllegalArgumentException이 발생하는지 검증 140 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 141 | userService.createUser(incompleteUser); 142 | }); 143 | 144 | assertThat(exception.getMessage()).isEqualTo("User email is required."); 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/controller/OrderControllerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.controller; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import io.github.junhkang.springboottesting.domain.OrderStatus; 5 | import io.github.junhkang.springboottesting.service.OrderService; 6 | import org.junit.jupiter.api.DisplayName; 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.Mockito; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 11 | import org.springframework.boot.test.mock.mockito.MockBean; 12 | import org.springframework.http.MediaType; 13 | import org.springframework.test.web.servlet.MockMvc; 14 | 15 | import java.time.LocalDateTime; 16 | import java.util.Arrays; 17 | import java.util.Collections; 18 | 19 | import static org.hamcrest.Matchers.is; 20 | import static org.mockito.ArgumentMatchers.any; 21 | import static org.mockito.ArgumentMatchers.anyLong; 22 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | 27 | @WebMvcTest(OrderController.class) 28 | @DisplayName("OrderController 테스트") 29 | class OrderControllerTest { 30 | 31 | @Autowired 32 | private MockMvc mockMvc; 33 | 34 | @MockBean 35 | private OrderService orderService; 36 | 37 | @Test 38 | @DisplayName("모든 주문 조회 테스트") 39 | void testGetAllOrders() throws Exception { 40 | // Given: Mocking service layer 41 | Order order = new Order(); 42 | order.setId(1L); 43 | order.setStatus(OrderStatus.PENDING); 44 | Mockito.when(orderService.getAllOrders()).thenReturn(Collections.singletonList(order)); 45 | 46 | // When & Then: GET 요청을 수행하고 응답을 검증 47 | mockMvc.perform(get("/orders")) 48 | .andExpect(status().isOk()) 49 | .andExpect(jsonPath("$[0].id", is(1))) 50 | .andExpect(jsonPath("$[0].status", is("PENDING"))); 51 | } 52 | 53 | @Test 54 | @DisplayName("주문 ID로 주문 조회 테스트") 55 | void testGetOrderById() throws Exception { 56 | // Given: Mocking service layer 57 | Order order = new Order(); 58 | order.setId(1L); 59 | order.setStatus(OrderStatus.PENDING); 60 | Mockito.when(orderService.getOrderById(1L)).thenReturn(order); 61 | 62 | // When & Then: GET 요청을 수행하고 응답을 검증 63 | mockMvc.perform(get("/orders/1")) 64 | .andExpect(status().isOk()) 65 | .andExpect(jsonPath("$.id", is(1))) 66 | .andExpect(jsonPath("$.status", is("PENDING"))); 67 | } 68 | 69 | @Test 70 | @DisplayName("주문 생성 테스트") 71 | void testCreateOrder() throws Exception { 72 | // Given: Mocking service layer 73 | Order order = new Order(); 74 | order.setId(1L); 75 | order.setStatus(OrderStatus.PENDING); 76 | Mockito.when(orderService.createOrder(anyLong(), anyLong(), any())).thenReturn(order); 77 | 78 | // When & Then: POST 요청을 수행하고 응답을 검증 79 | mockMvc.perform(post("/orders") 80 | .param("userId", "1") 81 | .param("productId", "1") 82 | .param("quantity", "2") 83 | .contentType(MediaType.APPLICATION_JSON)) 84 | .andExpect(status().isOk()) 85 | .andExpect(jsonPath("$.id", is(1))) 86 | .andExpect(jsonPath("$.status", is("PENDING"))); 87 | } 88 | 89 | @Test 90 | @DisplayName("주문 취소 테스트") 91 | void testCancelOrder() throws Exception { 92 | // Given: Mocking service layer 93 | Order order = new Order(); 94 | order.setId(1L); 95 | order.setStatus(OrderStatus.CANCELED); 96 | Mockito.when(orderService.cancelOrder(1L)).thenReturn(order); 97 | 98 | // When & Then: DELETE 요청을 수행하고 응답을 검증 99 | mockMvc.perform(delete("/orders/1/cancel")) 100 | .andExpect(status().isOk()) 101 | .andExpect(jsonPath("$.id", is(1))) 102 | .andExpect(jsonPath("$.status", is("CANCELED"))); 103 | } 104 | 105 | @Test 106 | @DisplayName("주문 수량 업데이트 테스트") 107 | void testUpdateOrderQuantity() throws Exception { 108 | // Given: Mocking service layer 109 | Order order = new Order(); 110 | order.setId(1L); 111 | order.setQuantity(5); 112 | Mockito.when(orderService.updateOrderQuantity(anyLong(), any())).thenReturn(order); 113 | 114 | // When & Then: PUT 요청을 수행하고 응답을 검증 115 | mockMvc.perform(put("/orders/1/quantity") 116 | .param("newQuantity", "5") 117 | .contentType(MediaType.APPLICATION_JSON)) 118 | .andExpect(status().isOk()) 119 | .andExpect(jsonPath("$.id", is(1))) 120 | .andExpect(jsonPath("$.quantity", is(5))); 121 | } 122 | 123 | @Test 124 | @DisplayName("사용자 ID로 주문 조회 테스트") 125 | void testGetOrdersByUserId() throws Exception { 126 | // Given: Mocking service layer 127 | Order order = new Order(); 128 | order.setId(1L); 129 | order.setStatus(OrderStatus.PENDING); 130 | Mockito.when(orderService.getOrdersByUserId(1L)).thenReturn(Collections.singletonList(order)); 131 | 132 | // When & Then: GET 요청을 수행하고 응답을 검증 133 | mockMvc.perform(get("/orders/user/1")) 134 | .andExpect(status().isOk()) 135 | .andExpect(jsonPath("$[0].id", is(1))) 136 | .andExpect(jsonPath("$[0].status", is("PENDING"))); 137 | } 138 | 139 | @Test 140 | @DisplayName("주문 날짜 범위로 주문 조회 테스트") 141 | void testGetOrdersByDateRange() throws Exception { 142 | // Given: Mocking service layer 143 | Order order1 = new Order(); 144 | order1.setId(1L); 145 | Order order2 = new Order(); 146 | order2.setId(2L); 147 | 148 | LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0); 149 | LocalDateTime endDate = LocalDateTime.of(2023, 12, 31, 23, 59); 150 | 151 | Mockito.when(orderService.getOrdersByDateRange(any(LocalDateTime.class), any(LocalDateTime.class))) 152 | .thenReturn(Arrays.asList(order1, order2)); 153 | 154 | // When & Then: GET 요청을 수행하고 응답을 검증 155 | mockMvc.perform(get("/orders/date") 156 | .param("startDate", "2023-01-01T00:00") 157 | .param("endDate", "2023-12-31T23:59")) 158 | .andExpect(status().isOk()) 159 | .andExpect(jsonPath("$[0].id", is(1))) 160 | .andExpect(jsonPath("$[1].id", is(2))); 161 | } 162 | 163 | @Test 164 | @DisplayName("주문 금액 계산 테스트") 165 | void testCalculateTotalAmount() throws Exception { 166 | // Given: Mocking service layer 167 | Mockito.when(orderService.calculateTotalAmount(1L)).thenReturn(500.0); 168 | 169 | // When & Then: GET 요청을 수행하고 응답을 검증 170 | mockMvc.perform(get("/orders/1/totalAmount")) 171 | .andExpect(status().isOk()) 172 | .andExpect(jsonPath("$", is(500.0))); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /src/main/java/io/github/junhkang/springboottesting/service/impl/MyBatisOrderServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.*; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.mybatis.OrderMapper; 6 | import io.github.junhkang.springboottesting.repository.mybatis.ProductMapper; 7 | import io.github.junhkang.springboottesting.repository.mybatis.UserMapper; 8 | import io.github.junhkang.springboottesting.service.OrderService; 9 | import org.springframework.context.annotation.Profile; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.transaction.annotation.Transactional; 12 | 13 | import java.time.LocalDateTime; 14 | import java.util.List; 15 | import java.util.stream.Collectors; 16 | 17 | @Service 18 | @Profile("mybatis") 19 | public class MyBatisOrderServiceImpl implements OrderService { 20 | 21 | private final OrderMapper orderMapper; 22 | private final UserMapper userMapper; 23 | private final ProductMapper productMapper; 24 | 25 | public MyBatisOrderServiceImpl(OrderMapper orderMapper, UserMapper userMapper, ProductMapper productMapper) { 26 | this.orderMapper = orderMapper; 27 | this.userMapper = userMapper; 28 | this.productMapper = productMapper; 29 | } 30 | 31 | @Override 32 | public List getAllOrders() { 33 | return orderMapper.findAll().stream() 34 | .map(dto -> mapToOrder(dto)) 35 | .collect(Collectors.toList()); 36 | } 37 | 38 | @Override 39 | public Order getOrderById(Long id) { 40 | OrderDTO dto = orderMapper.findById(id); 41 | if (dto == null) { 42 | throw new ResourceNotFoundException("Order not found with id " + id); 43 | } 44 | return mapToOrder(dto); 45 | } 46 | 47 | @Override 48 | @Transactional 49 | public Order createOrder(Long userId, Long productId, Integer quantity) { 50 | UserDTO userDTO = userMapper.findById(userId); 51 | if (userDTO == null) { 52 | throw new ResourceNotFoundException("User not found with id " + userId); 53 | } 54 | 55 | ProductDTO productDTO = productMapper.findById(productId); 56 | if (productDTO == null) { 57 | throw new ResourceNotFoundException("Product not found with id " + productId); 58 | } 59 | 60 | if (productDTO.getStock() < quantity) { 61 | throw new IllegalArgumentException("Insufficient stock for product id " + productId); 62 | } 63 | 64 | // 재고 업데이트 65 | productDTO.setStock(productDTO.getStock() - quantity); 66 | productMapper.update(productDTO); 67 | 68 | // 주문 생성 69 | OrderDTO orderDTO = new OrderDTO(); 70 | orderDTO.setOrderDate(LocalDateTime.now()); 71 | orderDTO.setUserId(userId); 72 | orderDTO.setProductId(productId); 73 | orderDTO.setQuantity(quantity); 74 | orderDTO.setStatus(OrderStatus.PENDING.name()); 75 | orderDTO.setTotalAmount(productDTO.getPrice() * quantity); 76 | orderMapper.insert(orderDTO); 77 | 78 | // 결과 반환 79 | return mapToOrder(orderDTO); 80 | } 81 | 82 | @Override 83 | @Transactional 84 | public Order cancelOrder(Long id) { 85 | OrderDTO dto = orderMapper.findById(id); 86 | if (dto == null) { 87 | throw new ResourceNotFoundException("Order not found with id " + id); 88 | } 89 | 90 | OrderStatus currentStatus = OrderStatus.valueOf(dto.getStatus()); 91 | if (currentStatus != OrderStatus.PENDING) { 92 | throw new IllegalArgumentException("Only pending orders can be canceled."); 93 | } 94 | 95 | // 상태 업데이트 96 | dto.setStatus(OrderStatus.CANCELED.name()); 97 | orderMapper.update(dto); 98 | 99 | // 재고 복구 100 | ProductDTO productDTO = productMapper.findById(dto.getProductId()); 101 | productDTO.setStock(productDTO.getStock() + dto.getQuantity()); 102 | productMapper.update(productDTO); 103 | 104 | return mapToOrder(dto); 105 | } 106 | 107 | @Override 108 | @Transactional 109 | public Order updateOrderQuantity(Long id, Integer newQuantity) { 110 | OrderDTO dto = orderMapper.findById(id); 111 | if (dto == null) { 112 | throw new ResourceNotFoundException("Order not found with id " + id); 113 | } 114 | 115 | OrderStatus currentStatus = OrderStatus.valueOf(dto.getStatus()); 116 | if (currentStatus != OrderStatus.PENDING) { 117 | throw new IllegalArgumentException("Only pending orders can be updated."); 118 | } 119 | 120 | ProductDTO productDTO = productMapper.findById(dto.getProductId()); 121 | int difference = newQuantity - dto.getQuantity(); 122 | 123 | if (difference > 0 && productDTO.getStock() < difference) { 124 | throw new IllegalArgumentException("Insufficient stock to increase quantity."); 125 | } 126 | 127 | // 재고 업데이트 128 | productDTO.setStock(productDTO.getStock() - difference); 129 | productMapper.update(productDTO); 130 | 131 | // 주문 업데이트 132 | dto.setQuantity(newQuantity); 133 | dto.setTotalAmount(productDTO.getPrice() * newQuantity); 134 | orderMapper.update(dto); 135 | 136 | return mapToOrder(dto); 137 | } 138 | 139 | @Override 140 | public List getOrdersByUserId(Long userId) { 141 | UserDTO userDTO = userMapper.findById(userId); 142 | if (userDTO == null) { 143 | throw new ResourceNotFoundException("User not found with id " + userId); 144 | } 145 | 146 | return orderMapper.findByUserId(userId).stream() 147 | .map(dto -> mapToOrder(dto)) 148 | .collect(Collectors.toList()); 149 | } 150 | 151 | @Override 152 | public List getOrdersByDateRange(LocalDateTime startDate, LocalDateTime endDate) { 153 | return orderMapper.findByOrderDateBetween(startDate, endDate).stream() 154 | .map(dto -> mapToOrder(dto)) 155 | .collect(Collectors.toList()); 156 | } 157 | 158 | @Override 159 | public Double calculateTotalAmount(Long id) { 160 | OrderDTO dto = orderMapper.findById(id); 161 | if (dto == null) { 162 | throw new ResourceNotFoundException("Order not found with id " + id); 163 | } 164 | return dto.getTotalAmount(); 165 | } 166 | 167 | // DTO를 Order 엔티티로 변환하는 메서드 168 | private Order mapToOrder(OrderDTO dto) { 169 | Order order = new Order(); 170 | order.setId(dto.getId()); 171 | order.setOrderDate(dto.getOrderDate()); 172 | 173 | // User 설정 174 | User user = new User(); 175 | user.setId(dto.getUserId()); 176 | user.setUsername(dto.getUsername()); 177 | user.setEmail(dto.getUserEmail()); 178 | order.setUser(user); 179 | 180 | // Product 설정 181 | Product product = new Product(); 182 | product.setId(dto.getProductId()); 183 | product.setName(dto.getProductName()); 184 | product.setDescription(dto.getProductDescription()); 185 | product.setPrice(dto.getProductPrice()); 186 | product.setStock(dto.getProductStock()); 187 | order.setProduct(product); 188 | 189 | order.setQuantity(dto.getQuantity()); 190 | order.setStatus(OrderStatus.valueOf(dto.getStatus())); 191 | order.setTotalAmount(dto.getTotalAmount()); 192 | 193 | return order; 194 | } 195 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/JpaUserServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.User; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.jpa.UserRepository; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Nested; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.test.context.ActiveProfiles; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | import java.util.List; 17 | 18 | import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 19 | import static org.junit.jupiter.api.Assertions.*; 20 | 21 | /** 22 | * 테스트 클래스: JpaUserServiceImplTest 23 | * 24 | * 이 클래스는 JpaUserServiceImpl 서비스 구현체의 비즈니스 로직을 검증하기 위한 단위 테스트를 제공합니다. 25 | * @DataJpaTest 어노테이션을 사용하여 JPA 관련 컴포넌트만 로드하고, @ActiveProfiles("jpa")를 통해 26 | * 'jpa' 프로파일을 활성화하여 JPA 관련 설정과 빈만 로드합니다. 27 | */ 28 | @DataJpaTest 29 | @Import(JpaUserServiceImpl.class) 30 | @ActiveProfiles("jpa") 31 | class JpaUserServiceImplTest { 32 | 33 | @Autowired 34 | private UserRepository userRepository; 35 | 36 | @Autowired 37 | private JpaUserServiceImpl userService; 38 | 39 | private User testUser; 40 | 41 | /** 42 | * 테스트 전 데이터 초기화 43 | * 44 | * @BeforeEach 어노테이션을 사용하여 각 테스트 메서드 실행 전에 실행됩니다. 45 | * 테스트에 필요한 사용자를 생성 및 저장합니다. 46 | */ 47 | @BeforeEach 48 | void setUp() { 49 | // Given: 테스트에 사용할 사용자 생성 및 저장 50 | testUser = new User(); 51 | testUser.setUsername("test_user"); 52 | testUser.setEmail("test.user@example.com"); 53 | // 추가적인 필드가 있다면 설정 54 | userRepository.save(testUser); 55 | } 56 | 57 | /** 58 | * 조회 관련 테스트 그룹 59 | */ 60 | @Nested 61 | @DisplayName("조회 관련 테스트") 62 | class RetrievalTests { 63 | 64 | /** 65 | * 모든 사용자 조회 테스트 66 | */ 67 | @Test 68 | @DisplayName("모든 사용자 조회 테스트") 69 | void testGetAllUsers() { 70 | // Given: data.sql에서 미리 생성된 사용자들이 존재한다고 가정 71 | 72 | // When: 모든 사용자를 조회 73 | List users = userService.getAllUsers(); 74 | 75 | // Then: data.sql에서 미리 생성된 사용자 수 + 테스트에서 생성한 사용자 수를 검증 76 | // 예를 들어, data.sql에서 3개의 사용자가 미리 생성되어 있다고 가정하면 총 4개 77 | assertThat(users).hasSize(4); // data.sql에서 3개의 사용자 + setUp()에서 1개 78 | } 79 | 80 | /** 81 | * 사용자 ID로 사용자 조회 테스트 - 존재하는 ID 82 | */ 83 | @Test 84 | @DisplayName("사용자 ID로 사용자 조회 테스트 - 존재하는 ID") 85 | void testGetUserByIdExists() { 86 | // Given: 테스트에서 생성한 사용자의 ID 87 | 88 | // When: 존재하는 사용자 ID로 사용자를 조회 89 | User foundUser = userService.getUserById(testUser.getId()); 90 | 91 | // Then: 조회된 사용자가 존재하고, 상세 정보가 올바른지 검증 92 | assertThat(foundUser).isNotNull(); 93 | assertThat(foundUser.getId()).isEqualTo(testUser.getId()); 94 | assertThat(foundUser.getUsername()).isEqualTo("test_user"); 95 | assertThat(foundUser.getEmail()).isEqualTo("test.user@example.com"); 96 | } 97 | 98 | /** 99 | * 사용자 ID로 사용자 조회 테스트 - 존재하지 않는 ID 100 | */ 101 | @Test 102 | @DisplayName("사용자 ID로 사용자 조회 테스트 - 존재하지 않는 ID") 103 | void testGetUserByIdNotExists() { 104 | // Given: 존재하지 않는 사용자 ID 105 | Long nonExistentId = 999L; 106 | 107 | // When & Then: 사용자 조회 시 ResourceNotFoundException이 발생하는지 검증 108 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 109 | userService.getUserById(nonExistentId); 110 | }); 111 | 112 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentId); 113 | } 114 | } 115 | 116 | /** 117 | * 생성 관련 테스트 그룹 118 | */ 119 | @Nested 120 | @DisplayName("생성 관련 테스트") 121 | class CreationTests { 122 | 123 | /** 124 | * 사용자 생성 테스트 - 성공 케이스 125 | */ 126 | @Test 127 | @DisplayName("사용자 생성 테스트 - 성공 케이스") 128 | @Transactional 129 | void testCreateUserSuccess() { 130 | // Given: 새로 생성할 사용자 정보 131 | User newUser = new User(); 132 | newUser.setUsername("new_user"); 133 | newUser.setEmail("new.user@example.com"); 134 | // 추가적인 필드가 있다면 설정 135 | 136 | // When: 사용자 생성 137 | User createdUser = userService.createUser(newUser); 138 | 139 | // Then: 생성된 사용자가 정상적으로 저장되었는지 검증 140 | assertThat(createdUser).isNotNull(); 141 | assertThat(createdUser.getId()).isNotNull(); 142 | assertThat(createdUser.getUsername()).isEqualTo("new_user"); 143 | assertThat(createdUser.getEmail()).isEqualTo("new.user@example.com"); 144 | 145 | // Then: 데이터베이스에 저장된 사용자 수가 증가했는지 검증 146 | List users = userService.getAllUsers(); 147 | assertThat(users).hasSize(5); // data.sql에서 3개 + setUp()에서 1개 + 이 테스트에서 1개 = 5개 148 | } 149 | 150 | /** 151 | * 사용자 생성 테스트 - 실패 케이스 152 | */ 153 | @Nested 154 | @DisplayName("사용자 생성 테스트 - 실패 케이스") 155 | class CreateUserFailureTests { 156 | 157 | /** 158 | * 사용자 생성 테스트 - 필수 필드 누락 (사용자 이름) 159 | */ 160 | @Test 161 | @DisplayName("사용자 생성 테스트 - 필수 필드 누락 (사용자 이름)") 162 | void testCreateUserWithMissingUsername() { 163 | // Given: 사용자 이름이 누락된 사용자 정보 164 | User incompleteUser = new User(); 165 | incompleteUser.setEmail("incomplete.user@example.com"); 166 | // 추가적인 필드가 있다면 설정 167 | 168 | // When & Then: 사용자 생성 시 IllegalArgumentException이 발생하는지 검증 169 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 170 | userService.createUser(incompleteUser); 171 | }); 172 | 173 | assertThat(exception.getMessage()).isEqualTo("Username is required."); 174 | } 175 | 176 | /** 177 | * 사용자 생성 테스트 - 필수 필드 누락 (이메일) 178 | */ 179 | @Test 180 | @DisplayName("사용자 생성 테스트 - 필수 필드 누락 (이메일)") 181 | void testCreateUserWithMissingEmail() { 182 | // Given: 이메일이 누락된 사용자 정보 183 | User incompleteUser = new User(); 184 | incompleteUser.setUsername("incomplete_user"); 185 | // 추가적인 필드가 있다면 설정 186 | 187 | // When & Then: 사용자 생성 시 IllegalArgumentException이 발생하는지 검증 188 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 189 | userService.createUser(incompleteUser); 190 | }); 191 | 192 | assertThat(exception.getMessage()).isEqualTo("Email is required."); 193 | } 194 | 195 | /** 196 | * 사용자 생성 테스트 - 잘못된 이메일 형식 197 | */ 198 | @Test 199 | @DisplayName("사용자 생성 테스트 - 잘못된 이메일 형식") 200 | void testCreateUserWithInvalidEmail() { 201 | // Given: 잘못된 이메일 형식의 사용자 정보 202 | User invalidEmailUser = new User(); 203 | invalidEmailUser.setUsername("invalid_email_user"); 204 | invalidEmailUser.setEmail("invalid-email"); // 잘못된 형식 205 | 206 | // When & Then: 사용자 생성 시 IllegalArgumentException이 발생하는지 검증 207 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 208 | userService.createUser(invalidEmailUser); 209 | }); 210 | 211 | assertThat(exception.getMessage()).isEqualTo("Invalid email format."); 212 | } 213 | } 214 | } 215 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/JpaProductServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.Product; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.jpa.ProductRepository; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.DisplayName; 8 | import org.junit.jupiter.api.Nested; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.test.context.ActiveProfiles; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | import java.util.List; 17 | 18 | import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 19 | import static org.junit.jupiter.api.Assertions.*; 20 | 21 | /** 22 | * 테스트 클래스: JpaProductServiceImplTest 23 | * 24 | * 이 클래스는 JpaProductServiceImpl 서비스 구현체의 비즈니스 로직을 검증하기 위한 단위 테스트를 제공합니다. 25 | * @DataJpaTest 어노테이션을 사용하여 JPA 관련 컴포넌트만 로드하고, @ActiveProfiles("jpa")를 통해 26 | * 'jpa' 프로파일을 활성화하여 JPA 관련 설정과 빈만 로드합니다. 27 | */ 28 | @DataJpaTest 29 | @Import(JpaProductServiceImpl.class) 30 | @ActiveProfiles("jpa") 31 | class JpaProductServiceImplTest { 32 | 33 | @Autowired 34 | private ProductRepository productRepository; 35 | 36 | @Autowired 37 | private JpaProductServiceImpl productService; 38 | 39 | private Product testProduct; 40 | 41 | /** 42 | * 테스트 전 데이터 초기화 43 | * 44 | * @BeforeEach 어노테이션을 사용하여 각 테스트 메서드 실행 전에 실행됩니다. 45 | * 테스트에 필요한 상품을 생성 및 저장합니다. 46 | */ 47 | @BeforeEach 48 | void setUp() { 49 | // Given: 테스트에 사용할 상품 생성 및 저장 50 | testProduct = new Product(); 51 | testProduct.setName("Test Product"); 52 | testProduct.setDescription("Test Description"); 53 | testProduct.setPrice(100.0); 54 | testProduct.setStock(50); 55 | productRepository.save(testProduct); 56 | } 57 | 58 | /** 59 | * 조회 관련 테스트 그룹 60 | */ 61 | @Nested 62 | @DisplayName("조회 관련 테스트") 63 | class RetrievalTests { 64 | 65 | /** 66 | * 모든 상품 조회 테스트 67 | */ 68 | @Test 69 | @DisplayName("모든 상품 조회 테스트") 70 | void testGetAllProducts() { 71 | // Given: data.sql에서 미리 생성된 상품들이 존재한다고 가정 72 | 73 | // When: 모든 상품을 조회 74 | List products = productService.getAllProducts(); 75 | 76 | // Then: data.sql에서 미리 생성된 상품 수 + 테스트에서 생성한 상품 수를 검증 77 | // 예를 들어, data.sql에서 5개의 상품이 미리 생성되어 있다고 가정하면 총 6개 78 | assertThat(products).hasSize(6); // data.sql에서 5개의 상품 + setUp()에서 1개 79 | } 80 | 81 | /** 82 | * 상품 ID로 상품 조회 테스트 - 존재하는 ID 83 | */ 84 | @Test 85 | @DisplayName("상품 ID로 상품 조회 테스트 - 존재하는 ID") 86 | void testGetProductByIdExists() { 87 | // Given: 테스트에서 생성한 상품의 ID 88 | 89 | // When: 존재하는 상품 ID로 상품을 조회 90 | Product foundProduct = productService.getProductById(testProduct.getId()); 91 | 92 | // Then: 조회된 상품이 존재하고, 상세 정보가 올바른지 검증 93 | assertThat(foundProduct).isNotNull(); 94 | assertThat(foundProduct.getId()).isEqualTo(testProduct.getId()); 95 | assertThat(foundProduct.getName()).isEqualTo("Test Product"); 96 | assertThat(foundProduct.getDescription()).isEqualTo("Test Description"); 97 | assertThat(foundProduct.getPrice()).isEqualTo(100.0); 98 | assertThat(foundProduct.getStock()).isEqualTo(50); 99 | } 100 | 101 | /** 102 | * 상품 ID로 상품 조회 테스트 - 존재하지 않는 ID 103 | */ 104 | @Test 105 | @DisplayName("상품 ID로 상품 조회 테스트 - 존재하지 않는 ID") 106 | void testGetProductByIdNotExists() { 107 | // Given: 존재하지 않는 상품 ID 108 | Long nonExistentId = 999L; 109 | 110 | // When & Then: 상품 조회 시 ResourceNotFoundException이 발생하는지 검증 111 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 112 | productService.getProductById(nonExistentId); 113 | }); 114 | 115 | assertThat(exception.getMessage()).isEqualTo("Product not found with id " + nonExistentId); 116 | } 117 | } 118 | 119 | /** 120 | * 생성 및 수정 관련 테스트 그룹 121 | */ 122 | @Nested 123 | @DisplayName("생성 및 수정 관련 테스트") 124 | class CreationAndUpdateTests { 125 | 126 | /** 127 | * 상품 생성 테스트 - 성공 케이스 128 | */ 129 | @Test 130 | @DisplayName("상품 생성 테스트 - 성공 케이스") 131 | @Transactional 132 | void testCreateProductSuccess() { 133 | // Given: 새로 생성할 상품 정보 134 | Product newProduct = new Product(); 135 | newProduct.setName("New Product"); 136 | newProduct.setDescription("New Description"); 137 | newProduct.setPrice(200.0); 138 | newProduct.setStock(30); 139 | 140 | // When: 상품 생성 141 | Product createdProduct = productService.createProduct(newProduct); 142 | 143 | // Then: 생성된 상품이 정상적으로 저장되었는지 검증 144 | assertThat(createdProduct).isNotNull(); 145 | assertThat(createdProduct.getId()).isNotNull(); 146 | assertThat(createdProduct.getName()).isEqualTo("New Product"); 147 | assertThat(createdProduct.getDescription()).isEqualTo("New Description"); 148 | assertThat(createdProduct.getPrice()).isEqualTo(200.0); 149 | assertThat(createdProduct.getStock()).isEqualTo(30); 150 | 151 | // Then: 데이터베이스에 저장된 상품 수가 증가했는지 검증 152 | List products = productService.getAllProducts(); 153 | assertThat(products).hasSize(7); // data.sql에서 5개 + setUp()에서 1개 + 이 테스트에서 1개 = 7개 154 | } 155 | 156 | /** 157 | * 상품 생성 테스트 - 실패 케이스 (필수 필드 누락) 158 | */ 159 | @Test 160 | @DisplayName("상품 생성 테스트 - 실패 케이스 (필수 필드 누락)") 161 | void testCreateProductWithMissingFields() { 162 | // Given: 이름이 누락된 상품 정보 163 | Product incompleteProduct = new Product(); 164 | incompleteProduct.setDescription("Incomplete Description"); 165 | incompleteProduct.setPrice(150.0); 166 | incompleteProduct.setStock(20); 167 | 168 | // When & Then: 상품 생성 시 IllegalArgumentException이 발생하는지 검증 169 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 170 | productService.createProduct(incompleteProduct); 171 | }); 172 | 173 | assertThat(exception.getMessage()).isEqualTo("Product name is required."); 174 | } 175 | 176 | /** 177 | * 상품 생성 테스트 - 실패 케이스 (가격이 음수인 경우) 178 | */ 179 | @Test 180 | @DisplayName("상품 생성 테스트 - 실패 케이스 (가격이 음수인 경우)") 181 | void testCreateProductWithNegativePrice() { 182 | // Given: 가격이 음수인 상품 정보 183 | Product invalidPriceProduct = new Product(); 184 | invalidPriceProduct.setName("Invalid Price Product"); 185 | invalidPriceProduct.setDescription("Invalid Price Description"); 186 | invalidPriceProduct.setPrice(-50.0); 187 | invalidPriceProduct.setStock(10); 188 | 189 | // When & Then: 상품 생성 시 IllegalArgumentException이 발생하는지 검증 190 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 191 | productService.createProduct(invalidPriceProduct); 192 | }); 193 | 194 | assertThat(exception.getMessage()).isEqualTo("Product price cannot be negative."); 195 | } 196 | 197 | /** 198 | * 상품 생성 테스트 - 실패 케이스 (재고가 음수인 경우) 199 | */ 200 | @Test 201 | @DisplayName("상품 생성 테스트 - 실패 케이스 (재고가 음수인 경우)") 202 | void testCreateProductWithNegativeStock() { 203 | // Given: 재고가 음수인 상품 정보 204 | Product invalidStockProduct = new Product(); 205 | invalidStockProduct.setName("Invalid Stock Product"); 206 | invalidStockProduct.setDescription("Invalid Stock Description"); 207 | invalidStockProduct.setPrice(100.0); 208 | invalidStockProduct.setStock(-10); 209 | 210 | // When & Then: 상품 생성 시 IllegalArgumentException이 발생하는지 검증 211 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 212 | productService.createProduct(invalidStockProduct); 213 | }); 214 | 215 | assertThat(exception.getMessage()).isEqualTo("Product stock cannot be negative."); 216 | } 217 | } 218 | } -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/JpaOrderServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.Order; 4 | import io.github.junhkang.springboottesting.domain.OrderStatus; 5 | import io.github.junhkang.springboottesting.domain.Product; 6 | import io.github.junhkang.springboottesting.domain.User; 7 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 8 | import io.github.junhkang.springboottesting.repository.jpa.OrderRepository; 9 | import io.github.junhkang.springboottesting.repository.jpa.ProductRepository; 10 | import io.github.junhkang.springboottesting.repository.jpa.UserRepository; 11 | import org.junit.jupiter.api.*; 12 | import org.junit.jupiter.params.ParameterizedTest; 13 | import org.junit.jupiter.params.provider.CsvSource; 14 | import org.junit.jupiter.params.provider.ValueSource; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 17 | import org.springframework.context.annotation.Import; 18 | import org.springframework.test.context.ActiveProfiles; 19 | import org.springframework.transaction.annotation.Transactional; 20 | 21 | import java.time.LocalDateTime; 22 | import java.util.List; 23 | import java.util.concurrent.TimeUnit; 24 | 25 | import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 26 | import static org.junit.jupiter.api.Assertions.*; 27 | 28 | /** 29 | * 테스트 클래스: JpaOrderServiceImplTest 30 | * 31 | * 이 클래스는 JpaOrderServiceImpl 서비스 구현체의 비즈니스 로직을 검증하기 위한 단위 테스트를 제공합니다. 32 | * @DataJpaTest 어노테이션을 사용하여 JPA 관련 컴포넌트만 로드하고, @ActiveProfiles("jpa")를 통해 33 | * 'jpa' 프로파일을 활성화하여 JPA 관련 설정과 빈만 로드합니다. 34 | */ 35 | @DataJpaTest 36 | @Import(JpaOrderServiceImpl.class) 37 | @ActiveProfiles("jpa") 38 | class JpaOrderServiceImplTest { 39 | 40 | @Autowired 41 | private OrderRepository orderRepository; 42 | 43 | @Autowired 44 | private UserRepository userRepository; 45 | 46 | @Autowired 47 | private ProductRepository productRepository; 48 | 49 | @Autowired 50 | private JpaOrderServiceImpl orderService; 51 | 52 | private User testUser; 53 | private Product testProduct; 54 | 55 | /** 56 | * 테스트 전 데이터 초기화 57 | * 58 | * @BeforeEach 어노테이션을 사용하여 각 테스트 메서드 실행 전에 실행됩니다. 59 | * 테스트에 필요한 사용자와 상품을 생성 및 저장합니다. 60 | */ 61 | @BeforeEach 62 | void setUp() { 63 | // Given: 테스트에 사용할 사용자 생성 및 저장 64 | testUser = new User(); 65 | testUser.setUsername("test_user"); 66 | testUser.setEmail("test.user@example.com"); 67 | userRepository.save(testUser); 68 | 69 | // Given: 테스트에 사용할 상품 생성 및 저장 70 | testProduct = new Product(); 71 | testProduct.setName("Test Product"); 72 | testProduct.setDescription("Test Description"); 73 | testProduct.setPrice(100.0); 74 | testProduct.setStock(50); 75 | productRepository.save(testProduct); 76 | } 77 | 78 | /** 79 | * 조회 관련 테스트 그룹 80 | */ 81 | @Nested 82 | @DisplayName("조회 관련 테스트") 83 | class RetrievalTests { 84 | 85 | /** 86 | * 모든 주문 조회 테스트 87 | */ 88 | @Test 89 | @DisplayName("모든 주문 조회 테스트") 90 | void testGetAllOrders() { 91 | // Given: 두 개의 주문을 생성 및 저장 (서비스 메서드 사용) 92 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 93 | Order order2 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 94 | 95 | // When: 모든 주문을 조회 96 | List orders = orderService.getAllOrders(); 97 | 98 | // Then: 데이터베이스에 저장된 주문 수를 검증 (data.sql에서 미리 생성된 주문 수를 고려) 99 | // 예: data.sql에서 5개의 주문이 미리 생성되어 있다고 가정하면 총 7개 100 | assertThat(orders).hasSize(7); // data.sql에서 5개의 주문 + 이 테스트에서 2개 101 | } 102 | 103 | @ParameterizedTest 104 | @ValueSource(ints = {1, 2, 3, 5, 10}) 105 | @DisplayName("다양한 수량으로 주문 생성 테스트") 106 | void testCreateOrderWithDifferentQuantities(int quantity) { 107 | Long userId = testUser.getId(); 108 | Long productId = testProduct.getId(); 109 | 110 | Order order = orderService.createOrder(userId, productId, quantity); 111 | 112 | assertThat(order.getQuantity()).isEqualTo(quantity); 113 | } 114 | /** 115 | * 주문 ID로 주문 조회 테스트 - 존재하는 ID 116 | */ 117 | @Test 118 | @DisplayName("주문 ID로 주문 조회 테스트 - 존재하는 ID") 119 | void testGetOrderByIdExists() { 120 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 121 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 3); 122 | 123 | // When: 존재하는 주문 ID로 주문을 조회 124 | Order foundOrder = orderService.getOrderById(order.getId()); 125 | 126 | // Then: 주문이 정상적으로 조회되고, 세부 사항이 올바른지 검증 127 | assertThat(foundOrder).isNotNull(); 128 | assertThat(foundOrder.getId()).isEqualTo(order.getId()); 129 | assertThat(foundOrder.getUser().getUsername()).isEqualTo("test_user"); 130 | assertThat(foundOrder.getProduct().getName()).isEqualTo("Test Product"); 131 | assertThat(foundOrder.getQuantity()).isEqualTo(3); 132 | assertThat(foundOrder.getStatus()).isEqualTo(OrderStatus.PENDING); 133 | assertThat(foundOrder.getTotalAmount()).isEqualTo(300.0); 134 | } 135 | 136 | /** 137 | * 주문 ID로 주문 조회 테스트 - 존재하지 않는 ID 138 | */ 139 | @Test 140 | @DisplayName("주문 ID로 주문 조회 테스트 - 존재하지 않는 ID") 141 | void testGetOrderByIdNotExists() { 142 | // Given: 존재하지 않는 주문 ID 143 | Long nonExistentId = 999L; 144 | 145 | // When & Then: 주문 조회 시 ResourceNotFoundException이 발생하는지 검증 146 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 147 | orderService.getOrderById(nonExistentId); 148 | }); 149 | 150 | assertThat(exception.getMessage()).isEqualTo("Order not found with id " + nonExistentId); 151 | } 152 | 153 | /** 154 | * 사용자 ID로 주문 조회 테스트 155 | */ 156 | @Test 157 | @DisplayName("사용자 ID로 주문 조회 테스트") 158 | void testGetOrdersByUserId() { 159 | // Given: 다른 사용자 생성 및 저장 160 | User anotherUser = new User(); 161 | anotherUser.setUsername("another_user"); 162 | anotherUser.setEmail("another.user@example.com"); 163 | userRepository.save(anotherUser); 164 | 165 | // Given: testUser의 주문 생성 및 저장 (서비스 메서드 사용) 166 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 167 | 168 | // Given: anotherUser의 주문 생성 및 저장 (서비스 메서드 사용) 169 | Order order2 = orderService.createOrder(anotherUser.getId(), testProduct.getId(), 3); 170 | 171 | // When: testUser의 모든 주문을 조회 172 | List userOrders = orderService.getOrdersByUserId(testUser.getId()); 173 | 174 | // Then: testUser의 주문만 조회되었는지 검증 175 | assertThat(userOrders).hasSize(1); 176 | assertThat(userOrders.get(0).getUser().getUsername()).isEqualTo("test_user"); 177 | } 178 | 179 | /** 180 | * 주문 날짜 범위로 주문 조회 테스트 181 | */ 182 | @Test 183 | @DisplayName("주문 날짜 범위로 주문 조회 테스트") 184 | void testGetOrdersByDateRange() { 185 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 186 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 187 | Order order2 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 188 | 189 | // 주문의 orderDate를 특정 날짜로 설정 (테스트 목적) 190 | LocalDateTime date1 = LocalDateTime.of(2023, 1, 1, 10, 0); 191 | LocalDateTime date2 = LocalDateTime.of(2023, 6, 15, 15, 30); 192 | Order savedOrder1 = orderRepository.findById(order1.getId()).orElseThrow(); 193 | savedOrder1.setOrderDate(date1); 194 | orderRepository.save(savedOrder1); 195 | 196 | Order savedOrder2 = orderRepository.findById(order2.getId()).orElseThrow(); 197 | savedOrder2.setOrderDate(date2); 198 | orderRepository.save(savedOrder2); 199 | 200 | // Given: data.sql에서 미리 생성된 5개의 주문 중 일부는 특정 날짜 범위에 속하도록 설정 201 | // (data.sql의 주문들이 이미 특정 날짜를 가지고 있다고 가정) 202 | 203 | // When: 특정 날짜 범위를 설정하여 주문을 조회 204 | LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0); 205 | LocalDateTime endDate = LocalDateTime.of(2023, 12, 31, 23, 59); 206 | List ordersInRange = orderService.getOrdersByDateRange(startDate, endDate); 207 | 208 | // Then: 설정한 날짜 범위 내에 있는 주문들이 정확히 조회되는지 검증 209 | assertThat(ordersInRange).hasSize(2); // 테스트에서 2개 210 | 211 | // Then: 조회된 주문들의 orderDate가 설정한 범위 내에 있는지 검증 212 | assertThat(ordersInRange).allMatch(order -> 213 | !order.getOrderDate().isBefore(startDate) && !order.getOrderDate().isAfter(endDate) 214 | ); 215 | 216 | // 추가 검증: 특정 주문이 포함되어 있는지 확인 217 | assertThat(ordersInRange) 218 | .extracting(Order::getId) 219 | .contains(savedOrder1.getId(), savedOrder2.getId()); 220 | } 221 | } 222 | 223 | /** 224 | * 생성 및 수정 관련 테스트 그룹 225 | */ 226 | @Nested 227 | @DisplayName("생성 및 수정 관련 테스트") 228 | class CreationAndUpdateTests { 229 | 230 | /** 231 | * 주문 생성 테스트 - 성공 케이스 232 | */ 233 | @Test 234 | @DisplayName("주문 생성 테스트 - 성공 케이스") 235 | @Transactional 236 | @Timeout(value = 500, unit = TimeUnit.MILLISECONDS) 237 | void testCreateOrderSuccess() { 238 | // Given: 유효한 사용자 ID, 상품 ID, 및 수량 239 | Long userId = testUser.getId(); 240 | Long productId = testProduct.getId(); 241 | Integer quantity = 5; 242 | 243 | // When: 주문 생성 244 | Order createdOrder = orderService.createOrder(userId, productId, quantity); 245 | 246 | // Then: 주문이 정상적으로 생성되고, 관련 데이터가 올바르게 업데이트되었는지 검증 247 | assertThat(createdOrder).isNotNull(); 248 | assertThat(createdOrder.getId()).isNotNull(); 249 | assertThat(createdOrder.getUser().getId()).isEqualTo(userId); 250 | assertThat(createdOrder.getProduct().getId()).isEqualTo(productId); 251 | assertThat(createdOrder.getQuantity()).isEqualTo(quantity); 252 | assertThat(createdOrder.getStatus()).isEqualTo(OrderStatus.PENDING); 253 | assertThat(createdOrder.getTotalAmount()).isEqualTo(testProduct.getPrice() * quantity); 254 | 255 | // Then: 상품의 재고가 감소했는지 검증 256 | Product updatedProduct = productRepository.findById(productId).orElse(null); 257 | assertThat(updatedProduct).isNotNull(); 258 | assertThat(updatedProduct.getStock()).isEqualTo(45); // 50 - 5 = 45 259 | } 260 | @ParameterizedTest 261 | @CsvSource({ 262 | "test_user, test.user@example.com", 263 | "john_doe, john.doe@example.com" 264 | }) 265 | @DisplayName("다양한 사용자 이름 및 이메일로 주문 생성 테스트") 266 | void testCreateOrderWithDifferentUsers(String username, String email) { 267 | User user = new User(); 268 | user.setUsername(username); 269 | user.setEmail(email); 270 | userRepository.save(user); 271 | 272 | Long productId = testProduct.getId(); 273 | Order order = orderService.createOrder(user.getId(), productId, 1); 274 | 275 | assertThat(order.getUser().getUsername()).isEqualTo(username); 276 | assertThat(order.getUser().getEmail()).isEqualTo(email); 277 | } 278 | /** 279 | * 주문 생성 테스트 - 실패 케이스 (재고 부족) 280 | */ 281 | @Test 282 | @DisplayName("주문 생성 테스트 - 실패 케이스 (재고 부족)") 283 | void testCreateOrderInsufficientStock() { 284 | // Given: 유효한 사용자 ID, 상품 ID, 및 재고보다 많은 수량 285 | Long userId = testUser.getId(); 286 | Long productId = testProduct.getId(); 287 | Integer quantity = 100; // 재고 50보다 큼 288 | 289 | // When & Then: 주문 생성 시 IllegalArgumentException이 발생하는지 검증 290 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 291 | orderService.createOrder(userId, productId, quantity); 292 | }); 293 | 294 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock for product id " + productId); 295 | 296 | // Then: 상품의 재고가 변경되지 않았는지 검증 297 | Product updatedProduct = productRepository.findById(productId).orElse(null); 298 | assertThat(updatedProduct).isNotNull(); 299 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 재고 변동 없음 300 | } 301 | 302 | /** 303 | * 주문 수량 업데이트 테스트 - 성공 케이스 (증가) 304 | */ 305 | @Test 306 | @DisplayName("주문 수량 업데이트 테스트 - 성공 케이스 (증가)") 307 | @Transactional 308 | void testUpdateOrderQuantityIncrease() { 309 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 310 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 311 | 312 | // When: 주문 수량을 4로 업데이트 (증가) 313 | Integer newQuantity = 4; 314 | Order updatedOrder = orderService.updateOrderQuantity(order.getId(), newQuantity); 315 | 316 | // Then: 주문 수량과 총 금액이 올바르게 업데이트되었는지 검증 317 | assertThat(updatedOrder.getQuantity()).isEqualTo(newQuantity); 318 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(testProduct.getPrice() * newQuantity); 319 | 320 | // Then: 상품의 재고가 올바르게 감소했는지 검증 321 | Product updatedProduct = productRepository.findById(testProduct.getId()).orElse(null); 322 | assertThat(updatedProduct).isNotNull(); 323 | assertThat(updatedProduct.getStock()).isEqualTo(46); // 50 - 4 = 46 324 | } 325 | 326 | /** 327 | * 주문 수량 업데이트 테스트 - 실패 케이스 (재고 부족) 328 | */ 329 | @Test 330 | @DisplayName("주문 수량 업데이트 테스트 - 실패 케이스 (재고 부족)") 331 | void testUpdateOrderQuantityInsufficientStock() { 332 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 333 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 334 | 335 | // When & Then: 주문 수량을 재고를 초과하는 값으로 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 336 | Integer newQuantity = 100; // 재고 50 - 2 + 100 = 148 > 50 337 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 338 | orderService.updateOrderQuantity(order.getId(), newQuantity); 339 | }); 340 | 341 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock to increase quantity."); 342 | 343 | // Then: 주문 수량이 변경되지 않았는지 검증 344 | Order updatedOrder = orderRepository.findById(order.getId()).orElse(null); 345 | assertThat(updatedOrder).isNotNull(); 346 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 347 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 348 | 349 | // Then: 상품의 재고가 변경되지 않았는지 검증 350 | Product updatedProduct = productRepository.findById(testProduct.getId()).orElse(null); 351 | assertThat(updatedProduct).isNotNull(); 352 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 353 | } 354 | /** 355 | * 반복된 주문 생성 테스트 - 여러 번 주문 생성하여 성능 확인 356 | */ 357 | @RepeatedTest(value = 5, name = "주문 생성 반복 테스트 {currentRepetition}/{totalRepetitions}") 358 | @DisplayName("주문 생성 반복 테스트") 359 | @Transactional 360 | void testCreateOrderRepeated() { 361 | Long userId = testUser.getId(); 362 | Long productId = testProduct.getId(); 363 | Integer quantity = 3; 364 | 365 | Order createdOrder = orderService.createOrder(userId, productId, quantity); 366 | 367 | assertThat(createdOrder).isNotNull(); 368 | assertThat(createdOrder.getQuantity()).isEqualTo(quantity); 369 | assertThat(createdOrder.getStatus()).isEqualTo(OrderStatus.PENDING); 370 | } 371 | } 372 | 373 | /** 374 | * 취소 관련 테스트 그룹 375 | */ 376 | @Nested 377 | @DisplayName("취소 관련 테스트") 378 | class CancellationTests { 379 | 380 | /** 381 | * 주문 취소 테스트 - 성공 케이스 382 | */ 383 | @Test 384 | @DisplayName("주문 취소 테스트 - 성공 케이스") 385 | @Transactional 386 | void testCancelOrderSuccess() { 387 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 388 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 389 | 390 | // When: 주문을 취소 391 | Order canceledOrder = orderService.cancelOrder(order.getId()); 392 | 393 | // Then: 주문 상태가 CANCELED로 변경되었는지 검증 394 | assertThat(canceledOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); 395 | 396 | // Then: 상품의 재고가 복구되었는지 검증 397 | Product updatedProduct = productRepository.findById(testProduct.getId()).orElse(null); 398 | assertThat(updatedProduct).isNotNull(); 399 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 50 - 2 + 2 = 50 400 | } 401 | 402 | /** 403 | * 주문 취소 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님) 404 | */ 405 | @Test 406 | @DisplayName("주문 취소 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님)") 407 | void testCancelOrderNotPending() { 408 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 409 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 410 | 411 | // Given: 주문 상태를 COMPLETED로 변경 412 | order.setStatus(OrderStatus.COMPLETED); 413 | orderRepository.save(order); 414 | 415 | // When & Then: 주문을 취소 시도 시 IllegalArgumentException이 발생하는지 검증 416 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 417 | orderService.cancelOrder(order.getId()); 418 | }); 419 | 420 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be canceled."); 421 | 422 | // Then: 주문 상태가 변경되지 않았는지 검증 423 | Order updatedOrder = orderRepository.findById(order.getId()).orElse(null); 424 | assertThat(updatedOrder).isNotNull(); 425 | assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); 426 | 427 | // Then: 상품의 재고가 변경되지 않았는지 검증 428 | Product updatedProduct = productRepository.findById(testProduct.getId()).orElse(null); 429 | assertThat(updatedProduct).isNotNull(); 430 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 431 | } 432 | } 433 | 434 | /** 435 | * 주문 금액 계산 관련 테스트 그룹 436 | */ 437 | @Nested 438 | @DisplayName("주문 금액 계산 관련 테스트") 439 | class CalculateTotalAmountTests { 440 | 441 | /** 442 | * 주문 금액 계산 테스트 443 | */ 444 | @Test 445 | @DisplayName("주문 금액 계산 테스트") 446 | void testCalculateTotalAmount() { 447 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 448 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 5); 449 | 450 | // When: 주문의 총 금액을 계산 451 | Double totalAmount = orderService.calculateTotalAmount(order.getId()); 452 | 453 | // Then: 계산된 총 금액이 올바른지 검증 454 | assertThat(totalAmount).isEqualTo(500.0); 455 | } 456 | } 457 | 458 | /** 459 | * 예외 상황 관련 테스트 그룹 460 | */ 461 | @Nested 462 | @DisplayName("예외 상황 관련 테스트") 463 | class ExceptionTests { 464 | 465 | /** 466 | * 주문 생성 테스트 - 존재하지 않는 사용자 ID 467 | */ 468 | @Test 469 | @DisplayName("주문 생성 테스트 - 존재하지 않는 사용자 ID") 470 | void testCreateOrderWithNonExistentUser() { 471 | // Given: 존재하지 않는 사용자 ID, 유효한 상품 ID, 및 수량 472 | Long nonExistentUserId = 999L; 473 | Long productId = testProduct.getId(); 474 | Integer quantity = 1; 475 | 476 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 477 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 478 | orderService.createOrder(nonExistentUserId, productId, quantity); 479 | }); 480 | 481 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentUserId); 482 | } 483 | 484 | /** 485 | * 주문 생성 테스트 - 존재하지 않는 상품 ID 486 | */ 487 | @Test 488 | @DisplayName("주문 생성 테스트 - 존재하지 않는 상품 ID") 489 | void testCreateOrderWithNonExistentProduct() { 490 | // Given: 유효한 사용자 ID, 존재하지 않는 상품 ID, 및 수량 491 | Long userId = testUser.getId(); 492 | Long nonExistentProductId = 999L; 493 | Integer quantity = 1; 494 | 495 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 496 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 497 | orderService.createOrder(userId, nonExistentProductId, quantity); 498 | }); 499 | 500 | assertThat(exception.getMessage()).isEqualTo("Product not found with id " + nonExistentProductId); 501 | } 502 | 503 | /** 504 | * 주문 생성 테스트 - 재고 부족 505 | */ 506 | @Test 507 | @DisplayName("주문 생성 테스트 - 재고 부족") 508 | void testCreateOrderWithInsufficientStock() { 509 | // Given: 유효한 사용자 ID, 상품 ID, 및 재고보다 많은 수량 510 | Long userId = testUser.getId(); 511 | Long productId = testProduct.getId(); 512 | Integer quantity = 100; // 재고 50보다 큼 513 | 514 | // When & Then: 주문 생성 시 IllegalArgumentException이 발생하는지 검증 515 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 516 | orderService.createOrder(userId, productId, quantity); 517 | }); 518 | 519 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock for product id " + productId); 520 | 521 | // Then: 상품의 재고가 변경되지 않았는지 검증 522 | Product updatedProduct = productRepository.findById(productId).orElse(null); 523 | assertThat(updatedProduct).isNotNull(); 524 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 재고 변동 없음 525 | } 526 | 527 | /** 528 | * 주문 취소 테스트 - 주문 상태가 PENDING이 아님 529 | */ 530 | @Test 531 | @DisplayName("주문 취소 테스트 - 주문 상태가 PENDING이 아님") 532 | void testCancelOrderNotPending() { 533 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 534 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 535 | 536 | // Given: 주문 상태를 COMPLETED로 변경 537 | order.setStatus(OrderStatus.COMPLETED); 538 | orderRepository.save(order); 539 | 540 | // When & Then: 주문을 취소 시도 시 IllegalArgumentException이 발생하는지 검증 541 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 542 | orderService.cancelOrder(order.getId()); 543 | }); 544 | 545 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be canceled."); 546 | 547 | // Then: 주문 상태가 변경되지 않았는지 검증 548 | Order updatedOrder = orderRepository.findById(order.getId()).orElse(null); 549 | assertThat(updatedOrder).isNotNull(); 550 | assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); 551 | } 552 | 553 | /** 554 | * 주문 수량 업데이트 테스트 - 주문 상태가 PENDING이 아님 555 | */ 556 | @Test 557 | @DisplayName("주문 수량 업데이트 테스트 - 주문 상태가 PENDING이 아님") 558 | void testUpdateOrderQuantityNotPending() { 559 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 560 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 561 | 562 | // Given: 주문 상태를 COMPLETED로 변경 563 | order.setStatus(OrderStatus.COMPLETED); 564 | orderRepository.save(order); 565 | 566 | // When & Then: 주문 수량을 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 567 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 568 | orderService.updateOrderQuantity(order.getId(), 4); 569 | }); 570 | 571 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be updated."); 572 | 573 | // Then: 주문 수량이 변경되지 않았는지 검증 574 | Order updatedOrder = orderRepository.findById(order.getId()).orElse(null); 575 | assertThat(updatedOrder).isNotNull(); 576 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 577 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 578 | } 579 | 580 | /** 581 | * 주문 수량 업데이트 테스트 - 재고 부족 582 | */ 583 | @Test 584 | @DisplayName("주문 수량 업데이트 테스트 - 재고 부족") 585 | void testUpdateOrderQuantityInsufficientStock() { 586 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 587 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 588 | 589 | // When & Then: 주문 수량을 재고를 초과하는 값으로 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 590 | Integer newQuantity = 100; // 재고 50 - 2 + 100 = 148 > 50 591 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 592 | orderService.updateOrderQuantity(order.getId(), newQuantity); 593 | }); 594 | 595 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock to increase quantity."); 596 | 597 | // Then: 주문 수량이 변경되지 않았는지 검증 598 | Order updatedOrder = orderRepository.findById(order.getId()).orElse(null); 599 | assertThat(updatedOrder).isNotNull(); 600 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 601 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 602 | 603 | // Then: 상품의 재고가 변경되지 않았는지 검증 604 | Product updatedProduct = productRepository.findById(testProduct.getId()).orElse(null); 605 | assertThat(updatedProduct).isNotNull(); 606 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 607 | } 608 | } 609 | } -------------------------------------------------------------------------------- /src/test/java/io/github/junhkang/springboottesting/service/impl/MyBatisOrderServiceImplTest.java: -------------------------------------------------------------------------------- 1 | package io.github.junhkang.springboottesting.service.impl; 2 | 3 | import io.github.junhkang.springboottesting.domain.*; 4 | import io.github.junhkang.springboottesting.exception.ResourceNotFoundException; 5 | import io.github.junhkang.springboottesting.repository.mybatis.OrderMapper; 6 | import io.github.junhkang.springboottesting.repository.mybatis.ProductMapper; 7 | import io.github.junhkang.springboottesting.repository.mybatis.UserMapper; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.DisplayName; 10 | import org.junit.jupiter.api.Nested; 11 | import org.junit.jupiter.api.Test; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.context.annotation.Import; 15 | import org.springframework.test.context.ActiveProfiles; 16 | import org.springframework.transaction.annotation.Transactional; 17 | 18 | import java.time.LocalDateTime; 19 | import java.util.List; 20 | 21 | import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; 22 | import static org.junit.jupiter.api.Assertions.assertThrows; 23 | 24 | /** 25 | * 테스트 클래스: MyBatisOrderServiceImplTest 26 | * 27 | * 이 클래스는 MyBatisOrderServiceImpl 서비스 구현체의 비즈니스 로직을 검증하기 위한 단위 테스트를 제공합니다. 28 | * 29 | * 주요 특징: 30 | * - MyBatis는 SQL 쿼리를 직접 작성하고 매퍼를 통해 데이터베이스와 상호작용합니다. 31 | * - 이 테스트는 MyBatis 매퍼에서 작성된 SQL 쿼리가 의도한 대로 실행되고, 데이터베이스와의 상호작용이 올바른지 검증하는 것을 중점으로 합니다. 32 | * 33 | * 테스트 고려 사항: 34 | * - SQL 쿼리의 정확성: 매퍼에 작성된 SQL 쿼리가 기대한 결과를 반환하는지 확인합니다. 35 | * - 매퍼 파일 매핑: MyBatis XML 매퍼 파일에서 정의된 쿼리와 객체 간 매핑이 올바르게 동작하는지 검증합니다. 36 | * - 동적 SQL: 특정 조건에 따라 쿼리가 동적으로 변하는 경우, 해당 동적 SQL이 올바르게 생성되고 실행되는지 테스트합니다. 37 | * - 성능 검토: 복잡한 SQL 쿼리가 올바르게 동작하는지 및 성능 상의 문제가 없는지를 추가적으로 검토할 수 있습니다. 38 | * 39 | * @SpringBootTest 어노테이션을 사용하여 MyBatis와 관련된 모든 컴포넌트를 로드하며, 40 | * @Import를 통해 MyBatisOrderServiceImpl 클래스를 로드하여 이 구현체를 테스트합니다. 41 | * @ActiveProfiles("mybatis") 어노테이션을 통해 'mybatis' 프로파일을 활성화하여 42 | * MyBatis 관련 설정과 빈이 올바르게 로드되는지 검증합니다. 43 | */ 44 | @SpringBootTest 45 | @Import(MyBatisOrderServiceImpl.class) 46 | @ActiveProfiles("mybatis") 47 | @Transactional 48 | @DisplayName("MyBatisOrderServiceImplTest") 49 | class MyBatisOrderServiceImplTest { 50 | 51 | @Autowired 52 | private OrderMapper orderMapper; 53 | 54 | @Autowired 55 | private UserMapper userMapper; 56 | 57 | @Autowired 58 | private ProductMapper productMapper; 59 | 60 | @Autowired 61 | private MyBatisOrderServiceImpl orderService; 62 | 63 | private UserDTO testUser; 64 | private ProductDTO testProduct; 65 | 66 | /** 67 | * 테스트 전 데이터 초기화 68 | * 69 | * @BeforeEach 어노테이션을 사용하여 각 테스트 메서드 실행 전에 실행됩니다. 70 | * 테스트에 필요한 사용자와 상품을 생성 및 저장합니다. 71 | */ 72 | @BeforeEach 73 | void setUp() { 74 | // Given: 테스트에 사용할 사용자 생성 및 저장 75 | testUser = new UserDTO(); 76 | testUser.setUsername("test_user"); 77 | testUser.setEmail("test.user@example.com"); 78 | userMapper.insert(testUser); // insert 시 ID가 설정된다고 가정 79 | 80 | // Given: 테스트에 사용할 상품 생성 및 저장 81 | testProduct = new ProductDTO(); 82 | testProduct.setName("Test Product"); 83 | testProduct.setDescription("Test Description"); 84 | testProduct.setPrice(100.0); 85 | testProduct.setStock(50); 86 | productMapper.insert(testProduct); // insert 시 ID가 설정된다고 가정 87 | } 88 | 89 | /** 90 | * 조회 관련 테스트 그룹 91 | */ 92 | @Nested 93 | @DisplayName("조회 관련 테스트") 94 | class RetrievalTests { 95 | 96 | /** 97 | * 모든 주문 조회 테스트 98 | */ 99 | @Test 100 | @DisplayName("모든 주문 조회 테스트") 101 | void testGetAllOrders() { 102 | // Given: data.sql에서 미리 생성된 주문들이 존재한다고 가정 103 | // 예를 들어, data.sql에서 5개의 주문이 미리 생성되어 있다고 가정 104 | 105 | // Given: 두 개의 주문을 생성 및 저장 (서비스 메서드 사용) 106 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 107 | Order order2 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 108 | 109 | // When: 모든 주문을 조회 110 | List orders = orderService.getAllOrders(); 111 | 112 | // Then: 데이터베이스에 저장된 총 주문 수가 예상과 일치하는지 검증 113 | // 예: data.sql에서 5개의 주문 + 이 테스트에서 2개 = 총 7개 114 | assertThat(orders).hasSize(7); 115 | } 116 | 117 | /** 118 | * 주문 ID로 주문 조회 테스트 - 존재하는 ID 119 | */ 120 | @Test 121 | @DisplayName("주문 ID로 주문 조회 테스트 - 존재하는 ID") 122 | void testGetOrderByIdExists() { 123 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 124 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 3); 125 | 126 | // When: 존재하는 주문 ID로 주문을 조회 127 | Order foundOrder = orderService.getOrderById(order.getId()); 128 | 129 | // Then: 주문이 정상적으로 조회되고, 세부 사항이 올바른지 검증 130 | assertThat(foundOrder).isNotNull(); 131 | assertThat(foundOrder.getId()).isEqualTo(order.getId()); 132 | assertThat(foundOrder.getUser().getUsername()).isEqualTo("test_user"); 133 | assertThat(foundOrder.getProduct().getName()).isEqualTo("Test Product"); 134 | assertThat(foundOrder.getQuantity()).isEqualTo(3); 135 | assertThat(foundOrder.getStatus()).isEqualTo(OrderStatus.PENDING); 136 | assertThat(foundOrder.getTotalAmount()).isEqualTo(300.0); 137 | } 138 | 139 | /** 140 | * 주문 ID로 주문 조회 테스트 - 존재하지 않는 ID 141 | */ 142 | @Test 143 | @DisplayName("주문 ID로 주문 조회 테스트 - 존재하지 않는 ID") 144 | void testGetOrderByIdNotExists() { 145 | // Given: 존재하지 않는 주문 ID 146 | Long nonExistentId = 999L; 147 | 148 | // When & Then: 주문 조회 시 ResourceNotFoundException이 발생하는지 검증 149 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 150 | orderService.getOrderById(nonExistentId); 151 | }); 152 | 153 | assertThat(exception.getMessage()).isEqualTo("Order not found with id " + nonExistentId); 154 | } 155 | 156 | /** 157 | * 사용자 ID로 주문 조회 테스트 158 | */ 159 | @Test 160 | @DisplayName("사용자 ID로 주문 조회 테스트") 161 | void testGetOrdersByUserId() { 162 | // Given: 다른 사용자 생성 및 저장 163 | UserDTO anotherUser = new UserDTO(); 164 | anotherUser.setUsername("another_user"); 165 | anotherUser.setEmail("another.user@example.com"); 166 | userMapper.insert(anotherUser); 167 | 168 | // Given: testUser의 주문 생성 및 저장 (서비스 메서드 사용) 169 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 170 | 171 | // Given: anotherUser의 주문 생성 및 저장 (서비스 메서드 사용) 172 | Order order2 = orderService.createOrder(anotherUser.getId(), testProduct.getId(), 3); 173 | 174 | // When: testUser의 모든 주문을 조회 175 | List userOrders = orderService.getOrdersByUserId(testUser.getId()); 176 | 177 | // Then: testUser의 주문만 조회되었는지 검증 178 | assertThat(userOrders).hasSize(1); 179 | assertThat(userOrders.get(0).getUser().getUsername()).isEqualTo("test_user"); 180 | } 181 | 182 | /** 183 | * 주문 날짜 범위로 주문 조회 테스트 184 | */ 185 | @Test 186 | @DisplayName("주문 날짜 범위로 주문 조회 테스트") 187 | void testGetOrdersByDateRange() { 188 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 189 | Order order1 = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 190 | Order order2 = orderService.createOrder(testUser.getId(), testProduct.getId(), 1); 191 | 192 | // 주문의 orderDate를 특정 날짜로 설정 (테스트 목적) 193 | LocalDateTime date1 = LocalDateTime.of(2023, 1, 1, 10, 0); 194 | LocalDateTime date2 = LocalDateTime.of(2023, 6, 15, 15, 30); 195 | OrderDTO savedOrder1 = orderMapper.findById(order1.getId()); 196 | savedOrder1.setOrderDate(date1); 197 | orderMapper.update(savedOrder1); 198 | 199 | OrderDTO savedOrder2 = orderMapper.findById(order2.getId()); 200 | savedOrder2.setOrderDate(date2); 201 | orderMapper.update(savedOrder2); 202 | 203 | // When: 특정 날짜 범위를 설정하여 주문을 조회 204 | LocalDateTime startDate = LocalDateTime.of(2023, 1, 1, 0, 0); 205 | LocalDateTime endDate = LocalDateTime.of(2023, 12, 31, 23, 59); 206 | List ordersInRange = orderService.getOrdersByDateRange(startDate, endDate); 207 | 208 | // Then: 설정한 날짜 범위 내에 있는 주문들이 정확히 조회되는지 검증 209 | // 예: 이 테스트에서 생성한 2개 주문이 범위 내에 있으므로 총 2개 210 | assertThat(ordersInRange).hasSize(2); 211 | 212 | // Then: 조회된 주문들의 orderDate가 설정한 범위 내에 있는지 검증 213 | assertThat(ordersInRange).allMatch(order -> 214 | !order.getOrderDate().isBefore(startDate) && !order.getOrderDate().isAfter(endDate) 215 | ); 216 | 217 | // 추가 검증: 특정 주문이 포함되어 있는지 확인 218 | assertThat(ordersInRange) 219 | .extracting(Order::getId) 220 | .contains(savedOrder1.getId(), savedOrder2.getId()); 221 | } 222 | } 223 | 224 | /** 225 | * 생성 및 수정 관련 테스트 그룹 226 | */ 227 | @Nested 228 | @DisplayName("생성 및 수정 관련 테스트") 229 | class CreationAndUpdateTests { 230 | 231 | /** 232 | * 주문 생성 테스트 - 성공 케이스 233 | */ 234 | @Test 235 | @DisplayName("주문 생성 테스트 - 성공 케이스") 236 | void testCreateOrderSuccess() { 237 | // Given: 유효한 사용자 ID, 상품 ID, 및 수량 238 | Long userId = testUser.getId(); 239 | Long productId = testProduct.getId(); 240 | Integer quantity = 5; 241 | 242 | // When: 주문 생성 243 | Order createdOrder = orderService.createOrder(userId, productId, quantity); 244 | 245 | // Then: 주문이 정상적으로 생성되고, 관련 데이터가 올바르게 업데이트되었는지 검증 246 | assertThat(createdOrder).isNotNull(); 247 | assertThat(createdOrder.getId()).isNotNull(); 248 | assertThat(createdOrder.getUser().getId()).isEqualTo(userId); 249 | assertThat(createdOrder.getProduct().getId()).isEqualTo(productId); 250 | assertThat(createdOrder.getQuantity()).isEqualTo(quantity); 251 | assertThat(createdOrder.getStatus()).isEqualTo(OrderStatus.PENDING); 252 | assertThat(createdOrder.getTotalAmount()).isEqualTo(testProduct.getPrice() * quantity); 253 | 254 | // Then: 상품의 재고가 감소했는지 검증 255 | ProductDTO updatedProduct = productMapper.findById(productId); 256 | assertThat(updatedProduct).isNotNull(); 257 | assertThat(updatedProduct.getStock()).isEqualTo(45); // 50 - 5 = 45 258 | } 259 | 260 | /** 261 | * 주문 생성 테스트 - 실패 케이스 (존재하지 않는 사용자 ID) 262 | */ 263 | @Test 264 | @DisplayName("주문 생성 테스트 - 실패 케이스 (존재하지 않는 사용자 ID)") 265 | void testCreateOrderWithNonExistentUser() { 266 | // Given: 존재하지 않는 사용자 ID, 유효한 상품 ID, 및 수량 267 | Long nonExistentUserId = 999L; 268 | Long productId = testProduct.getId(); 269 | Integer quantity = 1; 270 | 271 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 272 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 273 | orderService.createOrder(nonExistentUserId, productId, quantity); 274 | }); 275 | 276 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentUserId); 277 | } 278 | 279 | /** 280 | * 주문 생성 테스트 - 실패 케이스 (존재하지 않는 상품 ID) 281 | */ 282 | @Test 283 | @DisplayName("주문 생성 테스트 - 실패 케이스 (존재하지 않는 상품 ID)") 284 | void testCreateOrderWithNonExistentProduct() { 285 | // Given: 유효한 사용자 ID, 존재하지 않는 상품 ID, 및 수량 286 | Long userId = testUser.getId(); 287 | Long nonExistentProductId = 999L; 288 | Integer quantity = 1; 289 | 290 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 291 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 292 | orderService.createOrder(userId, nonExistentProductId, quantity); 293 | }); 294 | 295 | assertThat(exception.getMessage()).isEqualTo("Product not found with id " + nonExistentProductId); 296 | } 297 | 298 | /** 299 | * 주문 생성 테스트 - 실패 케이스 (재고 부족) 300 | */ 301 | @Test 302 | @DisplayName("주문 생성 테스트 - 실패 케이스 (재고 부족)") 303 | void testCreateOrderWithInsufficientStock() { 304 | // Given: 유효한 사용자 ID, 상품 ID, 및 재고보다 많은 수량 305 | Long userId = testUser.getId(); 306 | Long productId = testProduct.getId(); 307 | Integer quantity = 100; // 재고 50보다 큼 308 | 309 | // When & Then: 주문 생성 시 IllegalArgumentException이 발생하는지 검증 310 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 311 | orderService.createOrder(userId, productId, quantity); 312 | }); 313 | 314 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock for product id " + productId); 315 | 316 | // Then: 상품의 재고가 변경되지 않았는지 검증 317 | ProductDTO updatedProduct = productMapper.findById(productId); 318 | assertThat(updatedProduct).isNotNull(); 319 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 재고 변동 없음 320 | } 321 | 322 | /** 323 | * 주문 수량 업데이트 테스트 - 성공 케이스 (증가) 324 | */ 325 | @Test 326 | @DisplayName("주문 수량 업데이트 테스트 - 성공 케이스 (증가)") 327 | void testUpdateOrderQuantityIncrease() { 328 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 329 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 330 | 331 | // When: 주문 수량을 4로 업데이트 (증가) 332 | Integer newQuantity = 4; 333 | Order updatedOrder = orderService.updateOrderQuantity(order.getId(), newQuantity); 334 | 335 | // Then: 주문 수량과 총 금액이 올바르게 업데이트되었는지 검증 336 | assertThat(updatedOrder.getQuantity()).isEqualTo(newQuantity); 337 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(testProduct.getPrice() * newQuantity); 338 | 339 | // Then: 상품의 재고가 올바르게 감소했는지 검증 340 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 341 | assertThat(updatedProduct).isNotNull(); 342 | assertThat(updatedProduct.getStock()).isEqualTo(46); // 50 - 4 = 46 343 | } 344 | 345 | /** 346 | * 주문 수량 업데이트 테스트 - 실패 케이스 (재고 부족) 347 | */ 348 | @Test 349 | @DisplayName("주문 수량 업데이트 테스트 - 실패 케이스 (재고 부족)") 350 | void testUpdateOrderQuantityInsufficientStock() { 351 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 352 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 353 | 354 | // When & Then: 주문 수량을 재고를 초과하는 값으로 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 355 | Integer newQuantity = 100; // 재고 50 - 2 + 100 = 148 > 50 356 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 357 | orderService.updateOrderQuantity(order.getId(), newQuantity); 358 | }); 359 | 360 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock to increase quantity."); 361 | 362 | // Then: 주문 수량이 변경되지 않았는지 검증 363 | OrderDTO updatedOrder = orderMapper.findById(order.getId()); 364 | assertThat(updatedOrder).isNotNull(); 365 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 366 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 367 | 368 | // Then: 상품의 재고가 변경되지 않았는지 검증 369 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 370 | assertThat(updatedProduct).isNotNull(); 371 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 372 | } 373 | 374 | /** 375 | * 주문 수량 업데이트 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님) 376 | */ 377 | @Test 378 | @DisplayName("주문 수량 업데이트 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님)") 379 | void testUpdateOrderQuantityNotPending() { 380 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 381 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 382 | 383 | // Given: 주문 상태를 COMPLETED로 변경 384 | OrderDTO dto = orderMapper.findById(order.getId()); 385 | dto.setStatus(OrderStatus.COMPLETED.name()); 386 | orderMapper.update(dto); 387 | 388 | // When & Then: 주문 수량을 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 389 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 390 | orderService.updateOrderQuantity(order.getId(), 4); 391 | }); 392 | 393 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be updated."); 394 | 395 | // Then: 주문 수량이 변경되지 않았는지 검증 396 | OrderDTO updatedOrder = orderMapper.findById(order.getId()); 397 | assertThat(updatedOrder).isNotNull(); 398 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 399 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 400 | 401 | // Then: 상품의 재고가 변경되지 않았는지 검증 402 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 403 | assertThat(updatedProduct).isNotNull(); 404 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 405 | } 406 | } 407 | 408 | /** 409 | * 취소 관련 테스트 그룹 410 | */ 411 | @Nested 412 | @DisplayName("취소 관련 테스트") 413 | class CancellationTests { 414 | 415 | /** 416 | * 주문 취소 테스트 - 성공 케이스 417 | */ 418 | @Test 419 | @DisplayName("주문 취소 테스트 - 성공 케이스") 420 | void testCancelOrderSuccess() { 421 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 422 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 423 | 424 | // When: 주문을 취소 425 | Order canceledOrder = orderService.cancelOrder(order.getId()); 426 | 427 | // Then: 주문 상태가 CANCELED로 변경되었는지 검증 428 | assertThat(canceledOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); 429 | 430 | // Then: 상품의 재고가 복구되었는지 검증 431 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 432 | assertThat(updatedProduct).isNotNull(); 433 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 50 - 2 + 2 = 50 434 | } 435 | 436 | /** 437 | * 주문 취소 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님) 438 | */ 439 | @Test 440 | @DisplayName("주문 취소 테스트 - 실패 케이스 (주문 상태가 PENDING이 아님)") 441 | void testCancelOrderNotPending() { 442 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 443 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 444 | 445 | // Given: 주문 상태를 COMPLETED로 변경 446 | OrderDTO dto = orderMapper.findById(order.getId()); 447 | dto.setStatus(OrderStatus.COMPLETED.name()); 448 | orderMapper.update(dto); 449 | 450 | // When & Then: 주문을 취소 시도 시 IllegalArgumentException이 발생하는지 검증 451 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 452 | orderService.cancelOrder(order.getId()); 453 | }); 454 | 455 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be canceled."); 456 | 457 | // Then: 주문 상태가 변경되지 않았는지 검증 458 | OrderDTO updatedOrder = orderMapper.findById(order.getId()); 459 | assertThat(updatedOrder).isNotNull(); 460 | assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED.toString()); 461 | 462 | // Then: 상품의 재고가 변경되지 않았는지 검증 463 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 464 | assertThat(updatedProduct).isNotNull(); 465 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 466 | } 467 | } 468 | 469 | /** 470 | * 주문 금액 계산 관련 테스트 그룹 471 | */ 472 | @Nested 473 | @DisplayName("주문 금액 계산 관련 테스트") 474 | class CalculateTotalAmountTests { 475 | 476 | /** 477 | * 주문 금액 계산 테스트 478 | */ 479 | @Test 480 | @DisplayName("주문 금액 계산 테스트") 481 | void testCalculateTotalAmount() { 482 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 483 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 5); 484 | 485 | // When: 주문의 총 금액을 계산 486 | Double totalAmount = orderService.calculateTotalAmount(order.getId()); 487 | 488 | // Then: 계산된 총 금액이 올바른지 검증 489 | assertThat(totalAmount).isEqualTo(500.0); 490 | } 491 | } 492 | 493 | /** 494 | * 예외 상황 관련 테스트 그룹 495 | */ 496 | @Nested 497 | @DisplayName("예외 상황 관련 테스트") 498 | class ExceptionTests { 499 | 500 | /** 501 | * 주문 생성 테스트 - 존재하지 않는 사용자 ID 502 | */ 503 | @Test 504 | @DisplayName("주문 생성 테스트 - 존재하지 않는 사용자 ID") 505 | void testCreateOrderWithNonExistentUser() { 506 | // Given: 존재하지 않는 사용자 ID, 유효한 상품 ID, 및 수량 507 | Long nonExistentUserId = 999L; 508 | Long productId = testProduct.getId(); 509 | Integer quantity = 1; 510 | 511 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 512 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 513 | orderService.createOrder(nonExistentUserId, productId, quantity); 514 | }); 515 | 516 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentUserId); 517 | } 518 | 519 | /** 520 | * 주문 생성 테스트 - 존재하지 않는 상품 ID 521 | */ 522 | @Test 523 | @DisplayName("주문 생성 테스트 - 존재하지 않는 상품 ID") 524 | void testCreateOrderWithNonExistentProduct() { 525 | // Given: 유효한 사용자 ID, 존재하지 않는 상품 ID, 및 수량 526 | Long userId = testUser.getId(); 527 | Long nonExistentProductId = 999L; 528 | Integer quantity = 1; 529 | 530 | // When & Then: 주문 생성 시 ResourceNotFoundException이 발생하는지 검증 531 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 532 | orderService.createOrder(userId, nonExistentProductId, quantity); 533 | }); 534 | 535 | assertThat(exception.getMessage()).isEqualTo("Product not found with id " + nonExistentProductId); 536 | } 537 | 538 | /** 539 | * 주문 생성 테스트 - 재고 부족 540 | */ 541 | @Test 542 | @DisplayName("주문 생성 테스트 - 재고 부족") 543 | void testCreateOrderWithInsufficientStock() { 544 | // Given: 유효한 사용자 ID, 상품 ID, 및 재고보다 많은 수량 545 | Long userId = testUser.getId(); 546 | Long productId = testProduct.getId(); 547 | Integer quantity = 100; // 재고 50보다 큼 548 | 549 | // When & Then: 주문 생성 시 IllegalArgumentException이 발생하는지 검증 550 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 551 | orderService.createOrder(userId, productId, quantity); 552 | }); 553 | 554 | assertThat(exception.getMessage()).isEqualTo("Insufficient stock for product id " + productId); 555 | 556 | // Then: 상품의 재고가 변경되지 않았는지 검증 557 | ProductDTO updatedProduct = productMapper.findById(productId); 558 | assertThat(updatedProduct).isNotNull(); 559 | assertThat(updatedProduct.getStock()).isEqualTo(50); // 재고 변동 없음 560 | } 561 | 562 | /** 563 | * 주문 취소 테스트 - 주문 상태가 PENDING이 아님 564 | */ 565 | @Test 566 | @DisplayName("주문 취소 테스트 - 주문 상태가 PENDING이 아님") 567 | void testCancelOrderNotPending() { 568 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 569 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 570 | 571 | // Given: 주문 상태를 COMPLETED로 변경 572 | OrderDTO dto = orderMapper.findById(order.getId()); 573 | dto.setStatus(OrderStatus.COMPLETED.name()); 574 | orderMapper.update(dto); 575 | 576 | // When & Then: 주문을 취소 시도 시 IllegalArgumentException이 발생하는지 검증 577 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 578 | orderService.cancelOrder(order.getId()); 579 | }); 580 | 581 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be canceled."); 582 | 583 | // Then: 주문 상태가 변경되지 않았는지 검증 584 | OrderDTO updatedOrder = orderMapper.findById(order.getId()); 585 | assertThat(updatedOrder).isNotNull(); 586 | assertThat(updatedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED.toString()); 587 | 588 | // Then: 상품의 재고가 변경되지 않았는지 검증 589 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 590 | assertThat(updatedProduct).isNotNull(); 591 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 592 | } 593 | 594 | /** 595 | * 주문 수량 업데이트 테스트 - 주문 상태가 PENDING이 아님 596 | */ 597 | @Test 598 | @DisplayName("주문 수량 업데이트 테스트 - 주문 상태가 PENDING이 아님") 599 | void testUpdateOrderQuantityNotPending() { 600 | // Given: 주문을 생성 및 저장 (서비스 메서드 사용) 601 | Order order = orderService.createOrder(testUser.getId(), testProduct.getId(), 2); 602 | 603 | // Given: 주문 상태를 COMPLETED로 변경 604 | OrderDTO dto = orderMapper.findById(order.getId()); 605 | dto.setStatus(OrderStatus.COMPLETED.name()); 606 | orderMapper.update(dto); 607 | 608 | // When & Then: 주문 수량을 업데이트 시도 시 IllegalArgumentException이 발생하는지 검증 609 | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { 610 | orderService.updateOrderQuantity(order.getId(), 4); 611 | }); 612 | 613 | assertThat(exception.getMessage()).isEqualTo("Only pending orders can be updated."); 614 | 615 | // Then: 주문 수량이 변경되지 않았는지 검증 616 | OrderDTO updatedOrder = orderMapper.findById(order.getId()); 617 | assertThat(updatedOrder).isNotNull(); 618 | assertThat(updatedOrder.getQuantity()).isEqualTo(2); 619 | assertThat(updatedOrder.getTotalAmount()).isEqualTo(200.0); 620 | 621 | // Then: 상품의 재고가 변경되지 않았는지 검증 622 | ProductDTO updatedProduct = productMapper.findById(testProduct.getId()); 623 | assertThat(updatedProduct).isNotNull(); 624 | assertThat(updatedProduct.getStock()).isEqualTo(48); // 50 - 2 = 48 625 | } 626 | /** 627 | * 주문 수량 업데이트 테스트 - 존재하지 않는 주문 ID 628 | */ 629 | @Test 630 | @DisplayName("주문 수량 업데이트 테스트 - 존재하지 않는 주문 ID") 631 | void testUpdateOrderQuantityNotExists() { 632 | // Given: 존재하지 않는 주문 ID 633 | Long nonExistentOrderId = 999L; 634 | 635 | // When & Then: 주문 수량 업데이트 시 ResourceNotFoundException 발생 여부 검증 636 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 637 | orderService.updateOrderQuantity(nonExistentOrderId, 5); 638 | }); 639 | 640 | assertThat(exception.getMessage()).isEqualTo("Order not found with id " + nonExistentOrderId); 641 | } 642 | /** 643 | * 주문 취소 테스트 - 존재하지 않는 주문 ID 644 | */ 645 | @Test 646 | @DisplayName("주문 취소 테스트 - 존재하지 않는 주문 ID") 647 | void testCancelOrderNotExists() { 648 | // Given: 존재하지 않는 주문 ID 649 | Long nonExistentOrderId = 999L; 650 | 651 | // When & Then: 주문 취소 시 ResourceNotFoundException 발생 여부 검증 652 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 653 | orderService.cancelOrder(nonExistentOrderId); 654 | }); 655 | 656 | assertThat(exception.getMessage()).isEqualTo("Order not found with id " + nonExistentOrderId); 657 | } 658 | /** 659 | * 사용자 ID로 주문 조회 테스트 - 존재하지 않는 사용자 ID 660 | */ 661 | @Test 662 | @DisplayName("사용자 ID로 주문 조회 테스트 - 존재하지 않는 사용자 ID") 663 | void testGetOrdersByUserIdNotExists() { 664 | // Given: 존재하지 않는 사용자 ID 665 | Long nonExistentUserId = 999L; 666 | 667 | // When & Then: 주문 조회 시 ResourceNotFoundException 발생 여부 검증 668 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 669 | orderService.getOrdersByUserId(nonExistentUserId); 670 | }); 671 | 672 | assertThat(exception.getMessage()).isEqualTo("User not found with id " + nonExistentUserId); 673 | } 674 | 675 | /** 676 | * 주문 금액 계산 테스트 - 존재하지 않는 주문 ID 677 | */ 678 | @Test 679 | @DisplayName("주문 금액 계산 테스트 - 존재하지 않는 주문 ID") 680 | void testCalculateTotalAmountNotExists() { 681 | // Given: 존재하지 않는 주문 ID 682 | Long nonExistentOrderId = 999L; 683 | 684 | // When & Then: 총 금액 계산 시 ResourceNotFoundException 발생 여부 검증 685 | ResourceNotFoundException exception = assertThrows(ResourceNotFoundException.class, () -> { 686 | orderService.calculateTotalAmount(nonExistentOrderId); 687 | }); 688 | 689 | assertThat(exception.getMessage()).isEqualTo("Order not found with id " + nonExistentOrderId); 690 | } 691 | } 692 | 693 | } --------------------------------------------------------------------------------