From 3915987049353cac717429db7b67342999499a12 Mon Sep 17 00:00:00 2001 From: anniemon Date: Thu, 9 Jan 2025 00:52:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=8B=A4=ED=8C=A8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/point-service.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/unit/point-service.spec.ts b/test/unit/point-service.spec.ts index 84631a7..ab5979f 100644 --- a/test/unit/point-service.spec.ts +++ b/test/unit/point-service.spec.ts @@ -117,6 +117,18 @@ describe('PointService', () => { NotFoundException, ); }); + + it('실패: 잔액 충전 실패 시 에러가 발생해야 한다.', async () => { + const pointDto = { userId: 1, amount: 100 }; + + userRepository.selectById.mockResolvedValueOnce({ id: 1, balance: 1000 }); + // XXX: 재할당하는 것보단 beforeEach에서 초기화하는 것이 좋을 듯.. + userRepository.chargeBalanceWithLock = jest + .fn() + .mockRejectedValueOnce(new Error('error')); + + await expect(pointService.chargePoint(pointDto)).rejects.toThrow(Error); + }); }); describe('usePointWithLock', () => { From a1550e49db9020df9825f002d91f093a471133a9 Mon Sep 17 00:00:00 2001 From: anniemon Date: Sat, 11 Jan 2025 23:35:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4,=20=EC=A3=BC=EB=AC=B8=20=ED=8C=8C=EC=82=AC?= =?UTF-8?q?=EB=93=9C=20tc=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/order-facade.spec.ts | 67 +++++++++++- test/unit/product-service.spec.ts | 165 ++++++++++++++++++++++++------ 2 files changed, 197 insertions(+), 35 deletions(-) diff --git a/test/unit/order-facade.spec.ts b/test/unit/order-facade.spec.ts index dd459bd..958a4fc 100644 --- a/test/unit/order-facade.spec.ts +++ b/test/unit/order-facade.spec.ts @@ -2,6 +2,7 @@ import { DataSource } from 'typeorm'; import { Test, TestingModule } from '@nestjs/testing'; import { OrderFacade } from '@domain/usecase'; import { OrderService, PointService, ProductService } from '@domain/services'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; describe('OrderFacade', () => { let orderFacade: OrderFacade; @@ -164,15 +165,15 @@ describe('OrderFacade', () => { }); describe('createOrderWithTransaction: 주문 생성 실패', () => { - it('모든 상품의 재고가 없으면 주문 생성이 실패하고 트랜잭션이 롤백되어야 한다.', async () => { + it('모든 상품의 재고가 없으면 NotFoundException을 발생시키고, 트랜잭션이 롤백되어야 한다.', async () => { const userId = 1; const items = [ { productId: 1, quantity: 1 }, { productId: 2, quantity: 2 }, ]; - productService.decrementStockWithLock.mockRejectedValueOnce( - new Error('Out of stock'), + productService.decrementStockWithLock.mockRejectedValue( + new NotFoundException('상품 재고가 부족합니다.'), ); productService.findProductsByIdsWithStock.mockResolvedValueOnce([]); pointService.usePointWithLock.mockResolvedValueOnce({ @@ -186,7 +187,7 @@ describe('OrderFacade', () => { userId, items, }), - ).rejects.toThrow(Error); + ).rejects.toThrow(new NotFoundException('상품 재고가 부족합니다.')); expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); expect(queryRunner.release).toHaveBeenCalled(); @@ -219,6 +220,7 @@ describe('OrderFacade', () => { items, }), ).rejects.toThrow(Error); + expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); expect(queryRunner.release).toHaveBeenCalled(); }); @@ -244,6 +246,63 @@ describe('OrderFacade', () => { await expect( orderFacade.createOrderWithTransaction({ userId, items }), ).rejects.toThrow(Error); + + expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(queryRunner.release).toHaveBeenCalled(); + }); + + it('포인트 부족 시 BadRequestException을 발생시키고, 트랜잭션이 롤백되어야 한다.', async () => { + const userId = 1; + const items = [ + { productId: 1, quantity: 1 }, + { productId: 2, quantity: 2 }, + ]; + + productService.decrementStockWithLock.mockResolvedValueOnce({ + inStockProductIds: [1, 2], + outOfStockProductIds: [], + }); + productService.findProductsByIdsWithStock.mockResolvedValueOnce([ + { id: 1, price: 100, quantity: 1 }, + { id: 2, price: 200, quantity: 2 }, + ]); + pointService.usePointWithLock.mockRejectedValueOnce( + new BadRequestException('잔액이 부족합니다.'), + ); + orderService.createOrder.mockResolvedValueOnce({ id: 1, userId, items }); + + await expect( + orderFacade.createOrderWithTransaction({ userId, items }), + ).rejects.toThrow(new BadRequestException('잔액이 부족합니다.')); + + expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(queryRunner.release).toHaveBeenCalled(); + }); + + it('유효하지 않은 사용자일 경우, NotFoundException을 발생시키고, 트랜잭션이 롤백되어야 한다.', async () => { + const userId = 1; + const items = [ + { productId: 1, quantity: 1 }, + { productId: 2, quantity: 2 }, + ]; + + pointService.usePointWithLock.mockRejectedValueOnce( + new NotFoundException('사용자를 찾을 수 없습니다.'), + ); + productService.decrementStockWithLock.mockResolvedValueOnce({ + inStockProductIds: [1, 2], + outOfStockProductIds: [], + }); + productService.findProductsByIdsWithStock.mockResolvedValueOnce([ + { id: 1, price: 100, quantity: 1 }, + { id: 2, price: 200, quantity: 2 }, + ]); + orderService.createOrder.mockResolvedValueOnce({ id: 1, userId, items }); + + await expect( + orderFacade.createOrderWithTransaction({ userId, items }), + ).rejects.toThrow(new NotFoundException('사용자를 찾을 수 없습니다.')); + expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); expect(queryRunner.release).toHaveBeenCalled(); }); diff --git a/test/unit/product-service.spec.ts b/test/unit/product-service.spec.ts index 8b84b1c..ccce32a 100644 --- a/test/unit/product-service.spec.ts +++ b/test/unit/product-service.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProductService } from '@domain/services'; import { ProductRepository, StockRepository } from '@domain/repositories'; +import { OutOfStockException } from '@domain/exceptions'; +import { NotFoundException } from '@nestjs/common'; describe('ProductService', () => { let productService: ProductService; @@ -32,43 +34,144 @@ describe('ProductService', () => { productService = moduleFixture.get(ProductService); }); - it('getProductByIdWithStock: 상품 아이디로 상품 조회 메서드를 호출해야 한다.', async () => { - const productId = 1; - const product = await productService.getProductByIdWithStock(productId); + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('상품 조회 성공', () => { + it('getProductByIdWithStock: 상품 아이디로 상품 조회 메서드를 호출해야 한다.', async () => { + const productId = 1; + const product = await productService.getProductByIdWithStock(productId); + + expect(product.id).toBe(productId); + expect(productRepository.getProductByIdWithStock).toHaveBeenCalledWith( + productId, + ); + expect(productRepository.getProductByIdWithStock).toHaveBeenCalledTimes( + 1, + ); + }); + + it('findProductsByIdsWithStock: 상품 아이디 배열로 상품 조회 메서드를 호출해야 한다.', async () => { + const productIds = [1, 2]; + const products = + await productService.findProductsByIdsWithStock(productIds); + + expect(products.length).toBe(productIds.length); + expect(productRepository.findProductsByIdsWithStock).toHaveBeenCalledWith( + productIds, + ); + expect( + productRepository.findProductsByIdsWithStock, + ).toHaveBeenCalledTimes(1); + }); + + it('findProductsByIdsWithStock: 상품이 없을 떄 빈 배열을 반환해야 한다.', async () => { + const productIds = [1, 2]; + productRepository.findProductsByIdsWithStock.mockResolvedValueOnce([]); + const products = + await productService.findProductsByIdsWithStock(productIds); + + expect(products.length).toBe(0); + }); + }); + + describe('상품 조회 실패', () => { + it('getProductByIdWithStock: 상품 조회 에러 발생 시 에러가 발생해야 한다.', async () => { + productRepository.getProductByIdWithStock.mockRejectedValueOnce( + new Error(), + ); + const productId = 1; + + await expect( + productService.getProductByIdWithStock(productId), + ).rejects.toThrow(); + }); - expect(product.id).toBe(productId); - expect(productRepository.getProductByIdWithStock).toHaveBeenCalledWith( - productId, - ); - expect(productRepository.getProductByIdWithStock).toHaveBeenCalledTimes(1); + it('getProductByIdWithStock: 상품이 없을 때 NotFoundException이 발생해야 한다.', async () => { + productRepository.getProductByIdWithStock.mockResolvedValueOnce(null); + const productId = 1; + + await expect( + productService.getProductByIdWithStock(productId), + ).rejects.toThrow(NotFoundException); + }); + + it('findProductsByIdsWithStock: 상품 조회 에러 발생 시 에러가 발생해야 한다.', async () => { + productRepository.findProductsByIdsWithStock.mockRejectedValueOnce( + new Error(), + ); + const productIds = [1, 2]; + + await expect( + productService.findProductsByIdsWithStock(productIds), + ).rejects.toThrow(new Error()); + }); }); - it('findProductsByIdsWithStock: 상품 아이디 배열로 상품 조회 메서드를 호출해야 한다.', async () => { - const productIds = [1, 2]; - const products = - await productService.findProductsByIdsWithStock(productIds); - - expect(products.length).toBe(productIds.length); - expect(productRepository.findProductsByIdsWithStock).toHaveBeenCalledWith( - productIds, - ); - expect(productRepository.findProductsByIdsWithStock).toHaveBeenCalledTimes( - 1, - ); + describe('상품 재고 차감 성공', () => { + it('decrementStockWithLock: 주문 상품 갯수만큼 상품 재고 차감 메서드를 호출해야 한다.', async () => { + const items = [ + { productId: 1, quantity: 1 }, + { productId: 2, quantity: 2 }, + ]; + await productService.decrementStockWithLock({ + items, + queryRunner: null, + }); + + expect(stockRepository.decrementStockWithLock).toHaveBeenCalledTimes( + items.length, + ); + }); + + it('decrementStockWithLock: 재고가 있는 상품과 없는 상품을 구분하여 반환한다.', async () => { + const items = [ + { productId: 1, quantity: 0 }, + { productId: 2, quantity: 2 }, + ]; + stockRepository.decrementStockWithLock.mockRejectedValueOnce( + new OutOfStockException(), + ); + + const res = await productService.decrementStockWithLock({ + items, + queryRunner: null, + }); + + expect(res.inStockProductIds).toEqual([2]); + expect(res.outOfStockProductIds).toEqual([1]); + }); }); - it('decrementStockWithLock: 주문 상품 갯수만큼 상품 재고 차감 메서드를 호출해야 한다.', async () => { - const items = [ - { productId: 1, quantity: 1 }, - { productId: 2, quantity: 2 }, - ]; - await productService.decrementStockWithLock({ - items, - queryRunner: null, + describe('상품 재고 차감 실패', () => { + it('decrementStockWithLock: 상품 재고 차감 에러 발생 시 에러가 발생해야 한다.', async () => { + stockRepository.decrementStockWithLock.mockRejectedValueOnce(new Error()); + const items = [{ productId: 1, quantity: 1 }]; + + await expect( + productService.decrementStockWithLock({ + items, + queryRunner: null, + }), + ).rejects.toThrow(); }); - expect(stockRepository.decrementStockWithLock).toHaveBeenCalledTimes( - items.length, - ); + it('decrementStockWithLock: 모든 상품이 재고가 없을 때 NotFoundException이 발생해야 한다.', async () => { + const items = [ + { productId: 1, quantity: 1 }, + { productId: 2, quantity: 1 }, + ]; + stockRepository.decrementStockWithLock.mockRejectedValue( + new OutOfStockException(), + ); + + await expect( + productService.decrementStockWithLock({ + items, + queryRunner: null, + }), + ).rejects.toThrow(new NotFoundException('상품 재고가 부족합니다.')); + }); }); });