오늘은 4주차에 했던 과제에 대해서 리뷰를 해볼까한다.
이번 피드백에서는 TDD 방식에 대한 영상도 추가로 주셨다. 심지어 내가 주로 쓰는 자바스크립트로 영상을 만들어주셔서 TDD에 대해 알아가는 계기가 되었다. TDD 영상을 보고 알게된 사실은 다른 포스팅에 적어놨다! ( TDD에 대해서 포스팅 )
- 함수(메서드) 라인에 대한 기준도 적용한다 (15라인으로 제한하는 규칙)
- 예외 상황에 대한 고민한다
- 비즈니스 로직과 UI 로직의 분리한다 (SRP) -> mvc 패턴으로
- 객체의 상태 접근을 제한한다 ( private class 필드로 구현 ) => 왜??
- 객체는 객체답게 사용한다 (데이터를 꺼내(get) 사용하기보다는, 데이터가 가지고 있는 객체가 스스로 처리할 수 있도록 구조를 변경)
- 필드(인스턴스 변수)의 수를 줄이기 위해 노력한다
- 성공하는 케이스 뿐만 아니라 예외 케이스도 테스트한다
- 테스트 코드도 코드다
- 테스트를 위한 코드는 구현 코드에서 분리되어야 한다
- 단위 테스트하기 어려운 코드를 단위 테스트하기
객체의 상태 접근을 제한해야 하는 이유
객체 지향 프로그래밍(OOP)에서 객체의 상태(필드)에 대한 접근을 제한하는 것은 캡슐화(encapsulation)의 핵심 원칙 중 하나이다.
1. 데이터 무결성 유지
외부 코드가 객체의 상태를 임의로 변경할 수 없도록 보호.
2. 객체의 내부 구현 변경 용이
외부에서 필드에 직접 접근하면, 내부 구현을 변경할 때 모든 외부 코드를 수정해야 하므로, setter를 사용해서 private 변수를 수정하는게 더 효과적이다.
3. 객체의 책임 분리
객체는 자신의 상태를 스스로 관리해야 하며, 외부에서 상태를 직접 조작하는 것은 객체의 책임을 침해하기 때문에 접근을 제한해야한다.
4. 정보 은닉
캡슐화를 통해 객체의 내부 동작을 외부로부터 숨길 수 있다.
외부에서는 객체의 상태를 알 필요 없이, 제공되는 메서드만 사용해 동작을 수행할 수 있다.
5. 디버깅 및 유지보수 용이
필드에 직접 접근하면, 상태 변경이 어디에서 이루어졌는지 추적하기 어렵다. 하지만, 상태 변경을 메서드를 통해 수행하면, 디버깅 과정에서 해당 메서드만 추적하면 된다.
6. 확장성과 다형성 지원
필드를 직접 노출하면 다형성(polymorphism)을 활용하기 어렵다.
메서드를 통해 상태를 제어하면, 서브클래스에서 해당 메서드를 오버라이드해 다양한 동작을 구현할 수 있다.
즉, 객체의 상태에 직접 접근을 제한하는 것은 데이터 무결성, 유지보수성, 확장성, 디버깅 용이성을 모두 향상시킬 수 있다. 이러한 이유로 필드를 private으로 설정하고, getter/setter 메서드를 이용해 상태를 제어하는 것이 권장된다.
const MissionUtils = require("@woowacourse/mission-utils");
class Lotto {
#numbers
constructor() {
this.#numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
}
}
----
class LottoMachine {
execute() {
const lotto = new Lotto()
}
}
위 예시를 보면 Lotto 클래스를 테스트하기가 쉽지가 않다. 생성과 동시에 constructor로 랜덤코드가 돌기 때문에 올바른 로또 번호가 생기는지 알 수 없다.
const MissionUtils = require("@woowacourse/mission-utils");
class Lotto {
#numbers
constructor(numbers) {
this.#numbers = numbers;
}
}
----
class LottoMachine {
execute() {
const numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
const lotto = new Lotto(numbers)
}
}
이렇게 변경할 경우, numbers라는 값을 외부에서 주입해주기 때문에 Lotto 클래스를 테스트코드로 테스트 해볼 수 있다. 이렇게 함으로써, Lotto 클래스 내부에서 validate 메소드를 돌려서 유효성 검사도 가능하다.
, 단위 테스트하기 어려운 코드를 단위 테스트하기 쉽게 만드는 방법은 테스트하기 어려운 의존성(랜덤 번호)을 외부에서 주입하거나 분리하여 테스트 가능한 상태로 만드는 것이다.
나도 저번 3주차 로또 과제를 할 때, 이거를 신경 못쓰고, Lotto 클래스 내부에서 랜덤번호를 호출하게끔 했다. 즉, Lotto 클래스에 대한 테스트를 제대로 못했다는 뜻이다. 이번 피드백을 보고 TDD의 중요성을 다시 한 번 깨닫게 되었고, 이번 4주차 과제는 최대한 TDD를 신경써서 작업하려고 노력했다.
-> 처음 학습목표를 이렇게 잡았지만, 과제 자체의 복잡성으로 인해 테스트코드도 제대로 이행하지 못했고, 함수 길이도 10라인이 넘어가는 부분들이 굉장히 많았다.
주제 : 구매자의 할인 혜택과 재고 상황을 고려하여 최종 결제 금액을 계산하고 안내하는 결제 시스템을 구현한다.
- 현재 편의점에 존재하는 메뉴 출력 (README.md 파일 제공)
- 사용자가 구매할 상품과 수량을 입력
- 사용자가 입력한 상품들 중, 프로모션 적용 상품이 있는지 확인
- 있다면 -> 수량 맞게 갖고 왔는지 확인 (적게 가져온 경우, ex. 2+1인데,2개만 가져온 경우)
- 현재 날짜가 프로모션 진행기간에 포함되는지 파악 (포함x -> 멤버십으로 jump)
- 현재 가져온 개수가 프로모션 혜택에 맞게 가져왔는지 체크
- 가져왔다면 멤버십으로 이동
- 안가져왔다면 아래 3,4 프로세스 진행
- 현재 남은 재고도 같이 파악해야함. (프로모션 2개 남아있는데, 2개 가져오면 1개 더 못줌. 프로모션 할인 적용 안된다고 알림(프로모션 재고 부족 메시지 jump) )
- 수량을 추가해서 프로모션 혜택을 받을 것인지?
- 없다면 -> 다음 섹션(멤버십)으로 넘어감.
- 멤버십 할인 적용 여부 물어보기
- 한다면 -> 멤버십 할인 30% (단, 프로모션 상품이 포함된 경우, 총액에서 프로모션 들어간 상품들 가격 제외하고 30%)
- 안한다면 -> 다음 섹션(영수증 출력)으로 넘어감.
- 영수증 출력
- 구매한 물품, 수량 및 금액
- 1 중에서 프로모션 해당 상품 및 갯수
- 총 구매액, 행사할인(프로모션), 멤버십 할인, 내실 돈
- 추가 구매 여부
- 한다면
- 재고가 업데이트 된 상품 목록 보여주기
- 다시 입력 받기 (처음)
- 아니면 -> 종료
-> 기본적인 로직은 이렇게 돌아간다! 하지만, 기능명세서를 정말 많이 수정을 거쳤다. 그냥 보기엔 굉장히 단순해보이지만, TDD와 섞어서 작업을 하려다보니, 기능이 계속 수정되고, 중간중간 예외 상황들과 전체적인 큰 틀을 고려하다보니, 계속 꼬이게 되었다..
초기엔 모든 기능을 클래스 형태로 구현하여 객체지향적으로 프로그래밍을 짜보려고 했다. 하지만, 과제 기능의 복잡성으로 인해서 어쩔 수 없이 model과 view만 클래스로 분리했고, controller는 함수형으로 작성하는 것이 더 적합하다고 판단했다. 이렇게 제작함으로써 코드의 가독성과 재사용성을 높였다. 또한 컨트롤러에서 사용하는 보조함수들을 따로 관리하기 위해 utils 폴더를 만들어 함수의 역할을 명확히 분리했다. (함수를 10줄 이하로 작성하기 위해 이 폴더를 제작하였다.)
model
controller
utils/function
order
promotion
validate
view
javascript-convenience-store-7-hoyychoi/
├── __tests__/
│ ├── ApplicationTest.js
│ ├── ImportMarkdownTest.js
│ ├── InventoryTest.js
│ ├── MembershipTest.js
│ ├── OrderTest.js
│ ├── PromotionTest.js
│ └── ReceiptTest.js
├── public/
│ ├── products.md
│ └── promotions.md
├── src/
│ ├── controllers/
│ │ ├── InventorySetupController.js
│ │ ├── MembershipController.js
│ │ ├── OrderController.js
│ │ ├── ProcessOrderController.js
│ │ ├── ProcessPromotionController.js
│ │ └── ReceiptController.js
│ ├── models/
│ │ ├── Inventory.js
│ │ ├── InventoryFileReader.js
│ │ ├── Order.js
│ │ └── Receipt.js
│ ├── utils/
│ │ ├── membership/
│ │ │ ├── handleMembership.js
│ │ │ └── membershipDiscount.js
│ │ ├── order/
│ │ │ ├── fillOrderProductDetails.js
│ │ │ ├── updateOrderProductWithAdditional.js
│ │ │ └── updateOrderProductWithNonPromotion.js
│ │ ├── promotion/
│ │ │ ├── checkPromotionAndStockConditions.js
│ │ │ ├── handlePromotionData.js
│ │ │ └── returnPromotionProducts.js
│ │ └── validate/
│ │ ├── validateOrderProduct.js
│ │ ├── validateOrderPromotion.js
│ │ ├── validateYesOrNo.js
│ │ ├── constants.js
│ │ └── ErrorHandler.js
│ ├── views/
│ │ ├── InputHandler.js
│ │ ├── OutputHandler.js
│ │ └── App.js
│ ├── index.js
├── .gitignore
├── .npmrc
├── package-lock.json
├── package.json
└── README.md
위에서 말했던 것 처럼, 이번 4주차 과제는 최대한 MVC 패턴을 바탕으로 코드를 구현하려고 노력했다. 그리고 모델별 테스트코드를 작성하면서 기능구현을 했기 때문에 테스트코드 파일도 저번 주차들의 과제들도 많다! (중간에 TDD 방식으로 하지 못했다,,😢 코드가 너무 복잡해지고, 계속되는 기능명세서 수정으로 인해 우선, 중간부터는 기능구현에 초점을 둘 수 밖엔,,)
위의 최종로직을 바탕으로 기능 구현한 것들을 리뷰해보겠다.
//InventorySetupController.js 파일
import Inventory from "../models/Inventory.js";
import InventoryFileReader from "../models/InventoryFileReader.js";
import { PRODUCTS_PATH, PROMOTIONS_PATH } from "../utils/constants.js";
export const InventorySetupController = () => {
const PATHS = [PRODUCTS_PATH, PROMOTIONS_PATH];
const data = PATHS.map((path) => new InventoryFileReader(path));
// data -> [products, promotions]
Inventory.initialize(...data); // Inventory 클래스의 초기화 메서드 호출
};
//InventoryFileReader.js 파일
import fs from "fs";
import path from "path";
class InventoryFileReader {
constructor(file) {
this.filePath = path.join(process.cwd(), file);
return this.readAndParse(this.filePath);
}
parsingData(data) {
const [headerLine, ...lines] = data
.split("\n")
.filter((line) => line.trim() !== "");
const headers = headerLine.split(",").map((header) => header.trim());
return lines.map((line) => {
const values = line.split(",").map((value) => value.trim());
return headers.reduce((obj, header, index) => {
if (header === "quantity") {
obj[header] = Number(values[index]);
} else {
if (values[index] === "null") obj[header] = null;
else obj[header] = values[index];
}
return obj;
}, {});
});
}
readAndParse() {
const data = fs.readFileSync(this.filePath, "utf-8");
return this.parsingData(data);
}
}
export default InventoryFileReader;
InventorySetupController
를 만들어서 InventoryFileReader를 거쳐서 나온 것을 Inventory 클래스에 데이터의 초기값으로 할당해줬다.
초기 값 data
에는 [products, promotions]을 데이터 형태로 만들어 저장을 하게끔 했다.
class Inventory {
static #products;
//[{name:"",price:'',quantity:'',promotion:''}]
static #promotions;
// 초기에 재고 및 프로모션 설정
static initialize(products = [], promotions = []) {
Inventory.#products = products;
Inventory.#promotions = promotions;
}
// 현재 재고 조회
static getProducts() {
return Inventory.#products;
}
// 재고 업데이트 //{name:"",number(수량):'',promotion:string}
static setProducts(orderProducts) {
orderProducts.forEach((orderProduct) => {
Inventory.#deductQuantity(orderProduct);
});
}
static #deductQuantity(orderProduct) {
for (let i = 0; i < Inventory.#products.length; i++) {
if (
Inventory.#products[i].name === orderProduct.name &&
((orderProduct.promotion &&
Inventory.#products[i].promotion !== null) ||
(!orderProduct.promotion &&
Inventory.#products[i].promotion === null))
) {
Inventory.#products[i].quantity -= orderProduct.number;
if (Inventory.#products[i].quantity <= 0) {
Inventory.#products[i].quantity = "재고없음";
}
}
}
}
// 프로모션 조회
static getPromotions() {
return Inventory.#promotions;
}
}
export default Inventory;
Inventory
는 클래스 자체로 동작하며, "싱글톤 패턴"과 유사하게 상태를 공유한다. (클래스 자체로 동작)
그리고, 재고 데이터는 하나이기 때문에 이를 관리하기 위해 static을 사용했다.
setProducts
메소드는 재고를 업데이트하는 메소드이다. order 즉, 주문이 완료가 되면 이것을 현재 재고에서 업데이트 차감해줘야하기 때문에 이를 만들었다. #deductQuantity
를 이용해 직접적으로 재고를 차감하며, 만약 해당 재고가 0일 경우엔, "재고없음"이라는 텍스트를 저장한다.
//App.js
const orderItem = await this.inputHandler.orderItem();
//orderItem -> Order 인스턴스
//inputHandler.js 내부
validateOrderItem(input) {
const inventoryProducts = Inventory.getProducts();
const orderItem = new Order(input);
const validInput = validateOrderProduct(
inventoryProducts,
orderItem.getOrderProducts()
);
if (validInput) return orderItem;
}
사용자가 inputHandler 클래스의 orderItem 메소드를 이용해 주문한 메뉴와 갯수를 orederItem
변수로 받는다.
inputHandler 내부에 orderItem
메소드의 또 내부엔 validateOrderItem
에는 input 값에 유효성을 체크하고, 정상일 경우(재고가 있거나, 제품을 똑바로 썼거나), 이를 바탕으로 Order 인스턴스를 하나 생성해서 return 해준다.
//Order.js
import ErrorHandler from "../utils/ErrorHandler.js";
import { BRACKET_REGEX, ERROR_MESSAGES } from "../utils/constants.js";
class Order {
#orderProducts;
//[{ name: "사이다", number: 2 },{ name: "콜라", number: 1 }];
constructor(orderProducts) {
this.#validate(orderProducts);
this.#orderProducts = this.parseOrderProducts(orderProducts);
}
#validate(orderProducts) {
if (orderProducts === "")
ErrorHandler.throwError(ERROR_MESSAGES.PRODUCT_NOT_FOUND);
}
getOrderProducts() {
return this.#orderProducts;
}
//{ name: "콜라", price: "1000", quantity: "10", promotion: "탄산2+1",buy:2,get:1},
setOrderProducts(orderProducts) {
this.#orderProducts = orderProducts;
}
parseOrderProducts(orderProducts) {
orderProducts = orderProducts.replace(BRACKET_REGEX, "");
return orderProducts.split(",").map((product) => {
const [name, number] = product.split("-");
return {
name,
number: parseInt(number, 10),
promotion: null,
buy: null,
get: null,
price: null,
};
});
}
}
export default Order;
Order
클래스는 우선 초기값으로 들어오는 사용자 text 값을(ex.[사이다-2],[콜라-3])parseOrderProducts
메소드에 집어 넣어 원하는 형태로 변경해준다. 나는 Order 인스턴스를 마지막 영수증 출력까지 계속 수정하고, 업데이트해서 쓸 예정이라 필요한 정보들은 우선 null값으로 채워서 인스턴스를 생성했다.
-> 구랴소 return 값에 사용자한테 입력받은 name과 number(구매한 수량)를 집어넣고, 나머지 promotion(프로모션 여부), buy(몇개? 사야?), get(몇개? 공짜?), price(가격)을 우선 null로 채웠다.
그리고 이렇게 생성된 인스턴스에서 getOrderProduct메소드를 이용해 validateOrderProduct
에 한 번 더 넣어준다.
validateOrderProduct
는 현재 내가 주문한 상품의 갯수가 재고(프로모션+일반) 상품보다 많은지 체크하는 메소드이다. 재고가 더 많아야 내가 주문한 상품이 유효하기 때문에 만약 적다면 에러를 발생시켰다.
const validInput = validateOrderProduct(
inventoryProducts,
orderItem.getOrderProducts()
);
//validateOrderProduct.js
import ErrorHandler from "../ErrorHandler.js";
import { ERROR_MESSAGES } from "../constants.js";
export const validateOrderProduct = (inventoryProducts, orderProducts) => {
return orderProducts.every((orderProduct) => {
const { matchingProducts, totalQuantity } = getMatchingProductsAndQuantity(
inventoryProducts,
orderProduct
);
return validateStock(matchingProducts, totalQuantity, orderProduct);
});
};
const getMatchingProductsAndQuantity = (inventoryProducts, orderProduct) => {
// 같은 이름을 가진 상품
const matchingProducts = inventoryProducts.filter(
(product) => product.name === orderProduct.name
);
// 같은 이름을 가진 상품들의 quantity를 모두 합산
const totalQuantity = matchingProducts.reduce(
(sum, product) => sum + product.quantity,
0
);
return { matchingProducts, totalQuantity };
};
const validateStock = (matchingProducts, totalQuantity, orderProduct) => {
if (matchingProducts.length === 0) {
ErrorHandler.throwError(ERROR_MESSAGES.PRODUCT_NOT_FOUND);
}
if (totalQuantity < orderProduct.number) {
ErrorHandler.throwError(ERROR_MESSAGES.INSUFFICIENT_STOCK);
}
return true;
};
validateOrderProduct
메소드를 거쳐 true가 나오게 되면, 아까 생성한 인스턴스를 상위 App.js
orderItem 변수에 집어넣는다.
**
// 가공된 orderProducts를 받아옴
const orderProducts = await OrderController(orderItem);
orderItem
(order-인스턴스)을 OrderController에 넣어준다.
// 우선 order가 들어와서 돌아가는 컨트롤러 핵심 로직
//OrderController.js
import Inventory from "../models/Inventory.js";
import { ProcessOrderController } from "./ProcessOrderController.js";
import { ProcessPromotionController } from "./ProcessPromotionController.js";
export const OrderController = async (orderItem) => {
const inventoryProducts = Inventory.getProducts();
const inventoryPromotions = Inventory.getPromotions();
const orderProducts = orderItem.getOrderProducts();
const filteredPromotionProducts = ProcessOrderController(
inventoryProducts,
orderProducts
);
// 프로모션 관련 컨트롤러
await ProcessPromotionController(
orderProducts,
filteredPromotionProducts,
inventoryPromotions
);
return orderProducts;
};
ProcessOrderController
를 이용해서 filteredPromotionProducts를 만든다. 이건 주문한 상품 중, 프로모션 상품이 있을 경우, 해당 관련된 상품을 order에 promotion으로 일단 매칭시키는 컨트롤러이다.
//ProcessOrderController.js 파일
import { returnPromotionProducts } from "../utils/promotion/returnPromotionProducts.js";
import { fillOrderProductDetails } from "../utils/order/fillOrderProductDetails.js";
export const ProcessOrderController = (inventoryProducts, orderProducts) => {
// 유효성 다 검증해서 들어온거니까, 일단 가공만 해주면 됨
const filteredPromotionProducts = returnPromotionProducts(
inventoryProducts,
orderProducts
);
fillOrderProductDetails(inventoryProducts, orderProducts);
return filteredPromotionProducts;
};
//returnPromotionProducts.js
export const returnPromotionProducts = (inventoryProducts, orderProducts) =>
inventoryProducts.filter(
(product) =>
orderProducts.some(
(orderProduct) => orderProduct.name === product.name
) && product.promotion !== null
);
//fillOrderProductDetails.js
export const fillOrderProductDetails = (inventoryProducts, orderProducts) => {
return orderProducts.map((orderProduct) => {
const matchingInventory = inventoryProducts.find(
(product) => product.name === orderProduct.name
);
if (matchingInventory.promotion !== null && orderProduct.promotion === null)
orderProduct.promotion = matchingInventory.promotion;
if (orderProduct.price === null)
orderProduct.price = matchingInventory.price;
});
};
order에 promotion 있는 경우, 임시로 promotion 있는 물건 다 promtion 처리해서 orderProduct 업데이트
(왜? 임시로? -> 갯수 부족할 경우, 나머지 물건은 promotion null 처리해줘야하기때문에)
price도 업데이트
// 프로모션 관련 컨트롤러
await ProcessPromotionController(
orderProducts,
filteredPromotionProducts,
inventoryPromotions
);
프로모션은 과제 명세에 나온 프로모션과 관련된 로직을 수행하기 위해 따로 ProcessPromotionController
를 만들어 수행했다.
-> if) 프로모션 상품이 없을 경우, 컨트롤러가 돌긴 돌지만, 아무 수행없이 return 된다..)
지금 생각해보니까,로직을 잘못 짠 것 같다. 프로모션 상품이 없을 경우엔 바로 멤버십 로직으로 가게끔 해야 효율성이 좋아지는데, 이를 한 번 수정해서 다시 작업 해봐야겠다.
파라미터 종류)
orderProducts
-> 현재 주문한 상품
filteredPromotionProducts
-> 주문한 상품 중, 프로모션 상품이 있는 경우, 프로모션만 필터링한 재고목록들
inventoryPromotions
->인벤토리에 적힌 프로모션 상품 특징 (ex. 2+1, 유효기간)
ProcessPromotionController 내부
//ProcessPromotionController.js
import { checkPromotionAndStockConditions } from "../utils/promotion/checkPromotionAndStockConditions.js";
import { handlePromotionData } from "../utils/promotion/handlePromotionData.js";
import { validateOrderPromotion } from "../utils/validate/validateOrderPromotion.js";
export const ProcessPromotionController = async (
orderProducts,
filteredPromotionProducts,
inventoryPromotions
) => {
const isPromotionValid = validateOrderPromotion(
filteredPromotionProducts,
inventoryPromotions
);
// 프로모션 가공
if (isPromotionValid) {
const promotionData = checkPromotionAndStockConditions(
orderProducts,
filteredPromotionProducts,
inventoryPromotions
);
await handlePromotionData(promotionData, orderProducts);
}
};
여기서는 프로모션과 관련된 테스트를 진행한다.
- promotion 물건이 있는지 & 기간 유효한지
- promotion 상품보다 많은지 적은지
- promotion에 맞게 개수 잘 가져왔는지
const isPromotionValid = validateOrderPromotion(
filteredPromotionProducts,
inventoryPromotions
);
isPromotionValid
값으로 true를 받으면, promotion 물건이 존재하고, 그 물건의 기간 또한 유효한 것이다.!
//validateOrderPromotion.js
import { DateTimes } from "@woowacourse/mission-utils";
export const validateOrderPromotion = (
filteredPromotionProducts,
currentPromotions
) => {
if (filteredPromotionProducts.length === 0) return false;
// true: 프로모션 날짜가 유효, false : 유효 x
return validateDatePromotion(filteredPromotionProducts, currentPromotions);
};
const validateDatePromotion = (
filteredPromotionProducts,
currentPromotions
) => {
const currentDate = DateTimes.now();
const isWithinPromotionPeriod = currentPromotions.some((promotion) => {
return (
filteredPromotionProducts.some(
(product) => product.promotion === promotion.name
) &&
new Date(promotion.start_date) <= currentDate &&
currentDate <= new Date(promotion.end_date)
);
});
return isWithinPromotionPeriod;
};
현재 파라미터로 받은 filteredPromotionProducts
에 아무것도 없다면 프로모션 상품이 주문되지 않은 것이므로, return false
를 진행한다. validateDatePromotion
함수에서는 현재 그 프로모션의 유효기간이 오늘 날짜에 포함되는지 체크를 해준다. 그리고 유효하다면 return true
를 진행한다.
checkPromotionAndStockConditions
함수 내부에서 현재 내가 주문한 프로모션 제품 갯수가 재고보다 많은지, 적은지 체크를 진행한다. (많다면 상관없다. 하지만, 적다면 프로모션을 적용하지 못하기 때문에 일반 상품을 구매할건지 물어봐야한다.)
// 프로모션 검증 및 재고 검증 함수
export const checkPromotionAndStockConditions = (
orderProducts,
filteredPromotionProducts,
currentPromotions
) => {
return orderProducts
.map((orderProduct) => {
const promotionDetails = findPromotionDetails(
orderProduct,
currentPromotions
);
if (promotionDetails) {
orderProduct.buy = Number(promotionDetails.buy);
orderProduct.get = Number(promotionDetails.get);
const totalRequired = calculateTotalRequired(promotionDetails);
const promotionProduct = findMatchingPromotionProduct(
orderProduct,
filteredPromotionProducts
);
if (promotionProduct) {
return processPromotionStock(
orderProduct,
promotionProduct,
totalRequired,
promotionDetails
);
}
}
return null;
})
.filter(Boolean);
};
orderProducts
를 돌면서 findPromotionDetails
를 체크한다. 이 함수는 현재 orderProduct의 promotion 상품의 promotion 정보를 찾는 함수이다.
const findPromotionDetails = (orderProduct, currentPromotions) => {
return currentPromotions.find(
(promotion) => promotion.name === orderProduct.promotion
);
};
findPromotionDetails
이 만약 존재한다면 orderProduct 객체에 buy
,get
값을 할당해준다. (이게 필요한 이유는 나중에 영수증 출력할 때, 프로모션 상품들을 따로 표기해야하기 때문에 추가 해줬다.)
calculateTotalRequired
를 이용해 해당 상품의 프로모션 조건(buy와 get)에 따라, 프로모션을 적용하기 위해 필요한 총 수량을 계산한다.
ex) buy: 2, get: 1일 경우, 총 필요한 수량은 3. (return 3)
findMatchingPromotionProduct
(재고)에서 현재 프로모션 주문상품으로 선택된 상품을 찾아온다.
// 프로모션 상품 찾기
const findMatchingPromotionProduct = (
orderProduct,
filteredPromotionProducts
) => {
return filteredPromotionProducts.find(
(product) =>
product.name === orderProduct.name &&
product.promotion === orderProduct.promotion
);
};
그리고, processPromotionStock
을 이용해 원래 우리의 조건을 검사한다.
promotion 상품보다 많은지 적은지
-> 적으면 handleInsufficientStock
함수로 들어가게 된다.
// 프로모션 상품 처리
const processPromotionStock = (
orderProduct,
promotionProduct,
totalRequired,
promotionDetails
) => {
const maxEligiblePromotions = calculateMaxEligiblePromotions(
promotionProduct,
totalRequired
);
if (promotionProduct.quantity <= orderProduct.number)
return handleInsufficientStock(orderProduct, maxEligiblePromotions);
return handleAdditionalStock(orderProduct, totalRequired, promotionDetails);
};
maxEligiblePromotions
은 특정 프로모션에서, 현재 재고로 최대 몇 개의 프로모션 조건을 만족할 수 있는지 계산해서 보내준다. 즉, 최대 구매 가능 갯수를 보내준다. (ex. 2개 남아있는데, 2+1이다? -> 그럼 프로모션 상품 구매 가능 갯수 === 0)
// 프로모션 상품 최대 구매 가능 갯수
const calculateMaxEligiblePromotions = (promotionProduct, totalRequired) => {
return Math.floor(promotionProduct.quantity / totalRequired) * totalRequired;
};
//handleInsufficientStock
// 프로모션 상품이 부족할 경우
const handleInsufficientStock = (orderProduct, maxEligiblePromotions) => {
const nonPromotionQuantity = orderProduct.number - maxEligiblePromotions;
return {
type: "INSUFFICIENT_STOCK",
message: `현재 ${orderProduct.name} ${nonPromotionQuantity}개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)\n`,
orderProduct,
nonPromotionQuantity,
};
};
handleInsufficientStock
함수의 return 값으로 type
, message
, orderProduct
, nonPromotionQuantity
를 보낸다.
프로모션 재고가 부족한 경우, 나머지 갯수는 일반 상품으로 구매할지 물어보는 질문을 상위로 던지는 것이다!
handleAdditionalStock
함수로 들어가서 우선 갯수 체크를 진행한다.
// 프로모션 상품이 추가로 필요할 경우
const handleAdditionalStock = (
orderProduct,
totalRequired,
promotionDetails
) => {
const additionalNeeded =
totalRequired - (orderProduct.number % totalRequired);
if (
additionalNeeded <= promotionDetails.get &&
orderProduct.number % totalRequired !== 0
)
return {
type: "ADDITIONAL_STOCK",
message: `현재 ${orderProduct.name}은(는) ${additionalNeeded}개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)\n`,
orderProduct,
additionalNeeded,
};
};
handleAdditionalStock
에서는 추가로 혜택 받을 수 있는 상품에 대한 갯수를 알려준다. (ex. 2+1 프로모션인데, 2개만 더 가져왔을 경우, 1개를 더 가져올 수 있다.)
const additionalNeeded =
totalRequired - (orderProduct.number % totalRequired);
ex)
totalRequired => 3 (2+1)
orderProduct.number => 2개를 가져왔을 경우,
3 - (2%3) => 1 (1개를 더 추가로 가져올 수 있는 메시지를 보냄.)
이렇게 Promotion 테스트를 다 돌고난 후, 새로운 값({type,message~~})을 가지고 새 배열이 나오게 된다.
그리고 그렇게 나온 새 배열은 상위에 있는 promotionData
에 들어가게 된다.
// 프로모션 가공
if (isPromotionValid) {
const promotionData = checkPromotionAndStockConditions(
orderProducts,
filteredPromotionProducts,
inventoryPromotions
);
await handlePromotionData(promotionData, orderProducts);
}
handlePromotionData
에는 promotion 테스트에 대한 메시지들과 orderProducts 가 들어가게 된다.
import InputHandler from "../../views/InputHandler.js";
import { updateOrderProductWithAdditional } from "../order/updateOrderProductWithAdditional.js";
import { updateOrderProductWithNonPromotion } from "../order/updateOrderProductWithNonPromotion.js";
export const handlePromotionData = async (promotionData, orderProducts) => {
const input = new InputHandler();
for (const data of promotionData) {
if (data.type === "INSUFFICIENT_STOCK") {
const answer = await input.promotionQuestion(data.message);
if (answer) {
updateOrderProductWithNonPromotion(
orderProducts,
data.orderProduct,
data.nonPromotionQuantity
);
}
} else if (data.type === "ADDITIONAL_STOCK") {
const answer = await input.promotionQuestion(data.message);
if (answer) {
updateOrderProductWithAdditional(
orderProducts,
data.orderProduct,
data.additionalNeeded
);
}
}
}
};
handlePromotionData
는 아까 그 메시지를 사용자에게 보여주는 역할을 한다. 그리고 그 메시지에 대한 답에 따라 프로모션 상품을 추가를 할지 / 부족한 경우) 일반 상품으로 전환할지에 대해 orderProducts를 재가공해줘야한다.
"INSUFFICIENT_STOCK"
(일반 상품 전환)
updateOrderProductWithNonPromotion
// 프르모션 재고가 없을 경우, 기존 상품으로 order 반영
export const updateOrderProductWithNonPromotion = (
orderProducts,
orderProduct,
nonPromotionQuantity
) => {
const remainingQuantity = orderProduct.number - nonPromotionQuantity;
orderProduct.number = remainingQuantity; // 기존 프로모션 적용된 수량으로 업데이트
orderProducts.push({
name: orderProduct.name,
number: nonPromotionQuantity,
promotion: null,
price: orderProduct.price,
});
};
orderProducts에 개수를 수정하고 일반상품을 추가로 배열에 넣어준다.
"ADDITIONAL_STOCK"
(프로모션 상품 추가)
updateOrderProductWithAdditional
// 프로모션 상품 추가로 구매하는 경우, 기존 order에 추가
export const updateOrderProductWithAdditional = (
orderProducts,
orderProduct,
additionalNeeded
) => {
const productIndex = orderProducts.findIndex(
(product) =>
product.name === orderProduct.name &&
product.promotion === orderProduct.promotion
);
if (productIndex !== -1) {
orderProducts[productIndex].number += additionalNeeded;
}
};
orderProducts에서 프로모션 상품을 찾은 후, 추가가능한 갯수만큼 + 해준다.
(프로모션 테스트 진행 후) 여기까지 진행을 하게 되면, orderProducts까지 업데이트가 완료되었다!!! -> 이제 이걸 바탕으로 재고 반영, 멤버십 할인, 영수증 출력만 진행하면 끝나게 된다.
지금까지 orderProducts에는 처음 주문+(추가주문)을 거쳐서 최종 주문상품들만 저장이 되어있다. 이곳에는 아래와 같은 형식으로 저장이 되어 있다.
[
{ name: "콜라", price: "1000", number: 10, promotion: "탄산2+1",buy:2,get:1},
{ name: "사이다", price: "1300", number: 5, promotion: null,buy:null,get:null},
]
이것들은 최종이기 때문에 개수들을 현재 재고에 반영을 해줘야한다. (재고를 - 해준다.)
Inventory
클래스의 setProducts
메소드를 이용해서 재고를 업데이트 시킨다.
// 재고 업데이트 //{name:"",price:"",number(수량):'',promotion:string,buy:null,get:null}
static setProducts(orderProducts) {
orderProducts.forEach((orderProduct) => {
Inventory.#deductQuantity(orderProduct);
});
}
static #deductQuantity(orderProduct) {
for (let i = 0; i < Inventory.#products.length; i++) {
if (
Inventory.#products[i].name === orderProduct.name &&
((orderProduct.promotion &&
Inventory.#products[i].promotion !== null) ||
(!orderProduct.promotion &&
Inventory.#products[i].promotion === null))
) {
Inventory.#products[i].quantity -= orderProduct.number;
if (Inventory.#products[i].quantity <= 0) {
Inventory.#products[i].quantity = "재고없음";
}
}
}
}
orderProducts를 돌면서 #deductQuantity
메소드를 호출한다. #deductQuantity
는 현재 재고에 상품이름+프로모션 종류가 맞는 상품의 quantity를 주문한 수량만큼 빼주는 메소드이다. 여기서 만약에 0이 된다면 해당 quantity에는 "재고없음"이라는 string이 들어가게 해놨다.
멤버십 역시 컨트롤러로 제작을 했다. 멤버십 같은 경우는, 유저의 입력값에 따라 30프로를 할지말지 정해야하며, 이 멤버십 할인은 프로모션 상품들은 제외한 나머지 상품들만 30프로를 해준다.
그러므로 컨트롤러를 만들고, 해당 orderProducts 객체를 파라미터로 넘겨줬다.
import { handleMembership } from "../utils/membership/handleMembership.js";
import { membershipDiscount } from "../utils/membership/membershipDiscount.js";
export const MembershipController = async (orderProducts) => {
const membershipStatus = await handleMembership();
if (membershipStatus) {
return membershipDiscount(orderProducts);
}
return 0;
};
handleMembership
메소드를 입력해 유저가 멤버십 할인을 받을지 안받을지 물어보는 프로세스를 넣었다. 여기서 yes를 눌러 즉, true 값이 나오게 되면 membershipDiscount
함수가 실행한다.
//membershipDiscount.js
import { MEMBERSHIP_DISCOUNT_RATE } from "../constants.js";
export const membershipDiscount = (orderProducts) => {
const total = orderProducts
.filter((product) => product.promotion === null)
.reduce(
(sum, product) => sum + product.number * parseInt(product.price, 10),
0
);
// 일반 상품들의 30% 할인 가격을 계산 return
return total * MEMBERSHIP_DISCOUNT_RATE;
};
그리고 30% 할인했을 때의 가격을 return 시켰다.!
이제 모든 작업이 끝났고, 현재 orderProducts와 할인금액으로 영수증을 가공하기만 하면 된다. 그래서 난 model로 receipt를 제작해서 작업했다.
import Receipt from "../models/Receipt.js";
export const ReceiptController = async (orderProducts, membershipDiscount) => {
const receipt = new Receipt(orderProducts, membershipDiscount);
receipt.printReceipt();
};
ReceiptController
에서 파라미터로 두 값을 받고, Receipt
인스턴스를 제작했다.
//Receipt.js
import { Console } from "@woowacourse/mission-utils";
class Receipt {
constructor(orderProducts, membershipDiscount) {
this.orderProducts = orderProducts;
this.membershipDiscount = membershipDiscount;
this.totalAmount = this.calculateTotalAmount();
this.freeItems = this.getFreeItems();
this.finalAmount =
this.totalAmount -
this.calculatePromotionDiscount() -
this.membershipDiscount;
}
// 주문한 상품들의 총 금액을 계산하여 반환
calculateTotalAmount() {
return this.orderProducts.reduce(
(sum, product) => sum + product.number * parseInt(product.price, 10),
0
);
}
// 프로모션 상품들 중, 공짜 상품의 금액을 계산하여 반환
calculatePromotionDiscount() {
return this.freeItems.reduce(
(sum, item) => sum + item.number * parseInt(item.price, 10),
0
);
}
// 프로모션 상품들 중, 공짜 상품들 반환
getFreeItems() {
return this.orderProducts
.filter((product) => product.promotion && product.buy && product.get)
.map((product) => {
const promotionUnit = product.buy + product.get;
const freeCount =
Math.floor(product.number / promotionUnit) * product.get;
return {
name: product.name,
number: freeCount,
price: product.price,
};
});
}
printReceipt() {
Console.print("==============W 편의점================");
Console.print("상품명\t\t수량\t\t금액");
this.orderProducts.forEach((product) => {
const productTotal = product.number * parseInt(product.price, 10);
Console.print(
`${product.name}\t\t${product.number}\t\t${productTotal.toLocaleString(
"ko-KR"
)}`
);
});
if (this.freeItems.length > 0) {
Console.print("=============증\t정===============");
this.freeItems.forEach((item) => {
Console.print(`${item.name}\t\t${item.number}`);
});
}
Console.print("====================================");
Console.print(
`총구매액\t${this.orderProducts.reduce(
(sum, p) => sum + p.number,
0
)}\t\t${this.totalAmount.toLocaleString("ko-KR")}`
);
Console.print(
`행사할인\t\t\t-${this.calculatePromotionDiscount().toLocaleString(
"ko-KR"
)}`
);
Console.print(
`멤버십할인\t\t\t-${this.membershipDiscount.toLocaleString("ko-KR")}`
);
Console.print(`내실돈\t\t\t\t${this.finalAmount.toLocaleString("ko-KR")}`);
}
}
export default Receipt;
이렇게 해서 printReceipt
를 호출하게 되면 기능명세서에서 원한 형식대로 출력된다.!
const additional = await this.inputHandler.additionalPurchase();
if (additional) await this.run(); // 재귀 호출로 run 메서드 다시 실행
additionalPurchase
메소드를 통해 additional
값이 true가 나오면 다시 지금까지 했던 로직을 다시 실행시켰다.
//App.js
import { ReceiptController } from "./controllers/\bReceiptController.js";
import { InventorySetupController } from "./controllers/InventorySetupController.js";
import { MembershipController } from "./controllers/MembershipController.js";
import { OrderController } from "./controllers/OrderController.js";
import Inventory from "./models/Inventory.js";
import InputHandler from "./views/InputHandler.js";
import OutputHandler from "./views/OutputHandler.js";
class App {
constructor() {
InventorySetupController();
this.inventory = Inventory;
this.inputHandler = new InputHandler();
this.outputHandler = new OutputHandler();
}
async run() {
this.outputHandler.printStoreIntroduce();
this.outputHandler.printInventory(this.inventory.getProducts());
const orderItem = await this.inputHandler.orderItem();
// 가공된 orderProducts를 받아옴
const orderProducts = await OrderController(orderItem);
// 재고 업데이트
this.inventory.setProducts(orderProducts);
const membershipDiscount = await MembershipController(orderProducts);
ReceiptController(orderProducts, membershipDiscount);
const additional = await this.inputHandler.additionalPurchase();
if (additional) await this.run(); // 재귀 호출로 run 메서드 다시 실행
}
}
export default App;
App.js
는 과제의 핵심 흐름을 제어하는 클래스이다.!!
InputHandler
클래스 같은 경우, 3주차 로또 과제와 동일하게 입력에 대한 공통 메소드를 만들어 에러가 발생했을 때, 다시 질문하게끔 만들었다.
async handleInput(prompt, validateFn) {
while (true) {
const input = await Console.readLineAsync(prompt);
try {
return validateFn(input);
} catch (error) {
Console.print(error.message);
}
}
}
추가적으로, yes 또는 no에 대한 질문들이 많았기 때문에, 해당 질문의 validateFn
으로 Yes 또는 No 입력시, boolean 값으로 return 해주는 함수를 따로 만들어서 공통으로 사용했다.
import ErrorHandler from "../ErrorHandler.js";
import { ERROR_MESSAGES } from "../constants.js";
export const validateYesOrNo = (input) => {
if (input === "Y" || input === "y") return true;
if (input === "N" || input === "n") return false;
return ErrorHandler.throwError(ERROR_MESSAGES.TEXT_NOT_VALID);
};
우선, 결론부터 말하면 망했다. 내 나름대로 TDD 원칙을 지키면서 코드를 짜려고 노력했지만, 정말 쉽지 않았다. 처음에 기능명세서를 적으며, 어떻게 구현을 할지에 대해 설계를 했다. 그리고, TDD 방식대로 테스트코드를 먼저 작성하고, 그에 맞는 모델들을 만들어나가면서 코드를 작성하려고 했다. 하지만 계속 바뀌는 기능명세서와 예외를 생각하다보니, 바뀌는 설계들로 인해 TDD를 중간에 포기하게 되었다. (마감시간이 점점 다가오니, 구현에 목표를 두고 해버렸다,,)
그래서 초반에 열심히 짜던 TDD를 우선 작성해보겠다.
1. 마크다운 파일 불러오기 테스트
//ImportMarkdownTest.js
import InventoryFileReader from "../src/models/InventoryFileReader";
import { PRODUCTS_PATH, PROMOTIONS_PATH } from "../src/utils/constants";
// 테스트 코드
describe("마크다운 파일 불러오기 테스트", () => {
test("products파일이 잘 불러와지는지", async () => {
const products = new InventoryFileReader(PRODUCTS_PATH);
expect(products).toEqual([
{ name: "콜라", price: "1000", quantity: 10, promotion: "탄산2+1" },
{ name: "콜라", price: "1000", quantity: 10, promotion: null },
{ name: "사이다", price: "1000", quantity: 8, promotion: "탄산2+1" },
{ name: "사이다", price: "1000", quantity: 7, promotion: null },
{
name: "오렌지주스",
price: "1800",
quantity: 9,
promotion: "MD추천상품",
},
{ name: "탄산수", price: "1200", quantity: 5, promotion: "탄산2+1" },
{ name: "물", price: "500", quantity: 10, promotion: null },
{ name: "비타민워터", price: "1500", quantity: 6, promotion: null },
{ name: "감자칩", price: "1500", quantity: 5, promotion: "반짝할인" },
{ name: "감자칩", price: "1500", quantity: 5, promotion: null },
{ name: "초코바", price: "1200", quantity: 5, promotion: "MD추천상품" },
{ name: "초코바", price: "1200", quantity: 5, promotion: null },
{ name: "에너지바", price: "2000", quantity: 5, promotion: null },
{ name: "정식도시락", price: "6400", quantity: 8, promotion: null },
{ name: "컵라면", price: "1700", quantity: 1, promotion: "MD추천상품" },
{ name: "컵라면", price: "1700", quantity: 10, promotion: null },
]);
});
test("promotions파일이 잘 불러와지는지", async () => {
const promotions = new InventoryFileReader(PROMOTIONS_PATH);
expect(promotions).toEqual([
{
name: "탄산2+1",
buy: "2",
get: "1",
start_date: "2024-01-01",
end_date: "2024-12-31",
},
{
name: "MD추천상품",
buy: "1",
get: "1",
start_date: "2024-01-01",
end_date: "2024-12-31",
},
{
name: "반짝할인",
buy: "1",
get: "1",
start_date: "2024-11-01",
end_date: "2024-11-30",
},
]);
});
});
InventoryFileReader
클래스를 제작하여 초기값으로 주소를 넣으면 원하는 데이터 형식으로 파싱이 잘 되는지 테스트해보았다. 이것도 처음엔 InventoryFileReader
내부에서 path까지 다 선언을 해서 작업을 했지만, 테스트 하기가 어려웠다. 그래서 공통피드백에 있던 어려운 의존성(절대경로 주소)을 외부에서 주입하게끔 분리하여 테스트 가능한 상태로 만들었다!
2. 구매 클래스 테스트
import Order from "../src/models/Order";
// 테스트 코드
describe("구매 클래스 테스트", () => {
test("구매할 품목 입력시, 원하는 객체로 저장되는지", async () => {
const example = "[사이다-2],[감자칩-1]";
const order = new Order(example);
expect(order.getOrderProducts()).toEqual([
{
name: "사이다",
number: 2,
price: null,
promotion: null,
buy: null,
get: null,
},
{
name: "감자칩",
number: 1,
price: null,
promotion: null,
buy: null,
get: null,
},
]);
});
test("구매할 품목 빈 값 입력시, 에러가 나는지", async () => {
const example = "";
expect(() => {
new Order(example);
}).toThrow();
});
});
Order 클래스
를 제작하여 주문한 상품(사용자가 입력)이 제대로 원하는 형식으로 잘 나오는지 테스트하는 코드였다. Order 클래스 내부에 변수를 건드리지 못하게끔 하여 호출시, getter
를 이용했다.
3. Inventory 클래스 테스트
import { InventorySetupController } from "../src/controllers/InventorySetupController.js";
import Inventory from "../src/models/Inventory.js";
// 테스트 코드
describe("인벤토리 클래스 테스트", () => {
test("인벤토리 setup 컨트롤러 확인", async () => {
InventorySetupController();
expect(Inventory.getProducts()).toEqual([
{ name: "콜라", price: "1000", quantity: 10, promotion: "탄산2+1" },
{ name: "콜라", price: "1000", quantity: 10, promotion: null },
{ name: "사이다", price: "1000", quantity: 8, promotion: "탄산2+1" },
{ name: "사이다", price: "1000", quantity: 7, promotion: null },
{
name: "오렌지주스",
price: "1800",
quantity: 9,
promotion: "MD추천상품",
},
{ name: "탄산수", price: "1200", quantity: 5, promotion: "탄산2+1" },
{ name: "물", price: "500", quantity: 10, promotion: null },
{ name: "비타민워터", price: "1500", quantity: 6, promotion: null },
{ name: "감자칩", price: "1500", quantity: 5, promotion: "반짝할인" },
{ name: "감자칩", price: "1500", quantity: 5, promotion: null },
{ name: "초코바", price: "1200", quantity: 5, promotion: "MD추천상품" },
{ name: "초코바", price: "1200", quantity: 5, promotion: null },
{ name: "에너지바", price: "2000", quantity: 5, promotion: null },
{ name: "정식도시락", price: "6400", quantity: 8, promotion: null },
{ name: "컵라면", price: "1700", quantity: 1, promotion: "MD추천상품" },
{ name: "컵라면", price: "1700", quantity: 10, promotion: null },
]);
});
test("인벤토리 재고 업데이트 확인", async () => {
InventorySetupController();
const orderItem = [
{
name: "사이다",
number: 6,
promotion: "탄산2+1",
price: "1000",
buy: 2,
get: 1,
},
{ name: "감자칩", number: 2, promotion: null, price: "1500" },
];
Inventory.setProducts(orderItem);
expect(Inventory.getProducts()).toEqual([
{ name: "콜라", price: "1000", quantity: 10, promotion: "탄산2+1" },
{ name: "콜라", price: "1000", quantity: 10, promotion: null },
{ name: "사이다", price: "1000", quantity: 2, promotion: "탄산2+1" },
{ name: "사이다", price: "1000", quantity: 7, promotion: null },
{
name: "오렌지주스",
price: "1800",
quantity: 9,
promotion: "MD추천상품",
},
{ name: "탄산수", price: "1200", quantity: 5, promotion: "탄산2+1" },
{ name: "물", price: "500", quantity: 10, promotion: null },
{ name: "비타민워터", price: "1500", quantity: 6, promotion: null },
{ name: "감자칩", price: "1500", quantity: 5, promotion: "반짝할인" },
{ name: "감자칩", price: "1500", quantity: 3, promotion: null },
{ name: "초코바", price: "1200", quantity: 5, promotion: "MD추천상품" },
{ name: "초코바", price: "1200", quantity: 5, promotion: null },
{ name: "에너지바", price: "2000", quantity: 5, promotion: null },
{ name: "정식도시락", price: "6400", quantity: 8, promotion: null },
{ name: "컵라면", price: "1700", quantity: 1, promotion: "MD추천상품" },
{ name: "컵라면", price: "1700", quantity: 10, promotion: null },
]);
});
});
InventorySetupController()
메소드를 통해 해당 마크다운 파일이 Inventory
클래스에 잘 저장이 되고, 저장이 잘 되었는지 확인하는 테스트이다. 그리고 두번째는 Inventory의 재고 값을 업데이트 했을 때, 업데이트가 잘 반영되는지 확인하는 테스트이다.
-> 사실 이렇게 테스트코드를 짜보았지만, 이게 정말 단위테스트인가? 에 대해서 고민을 많이 했다. 테스트를 위한 코드를 짜고 있다는 생각이 많이 들었다.
4. Promotion 테스트
import { InventorySetupController } from "../src/controllers/InventorySetupController";
import Inventory from "../src/models/Inventory";
import Order from "../src/models/Order";
import { returnPromotionProducts } from "../src/utils/promotion/returnPromotionProducts";
import { validateOrderPromotion } from "../src/utils/validate/validateOrderPromotion";
// 테스트 코드
describe("프로모션 테스트", () => {
test("주문한 제품에 프로모션 제품이 있다면 - 프로모션만 따로 가공", async () => {
InventorySetupController();
const inventoryProducts = Inventory.getProducts();
const orderItem = "[사이다-1],[감자칩-3]";
const order = new Order(orderItem);
let orderProducts = order.getOrderProducts();
const filteredPromotionProducts = returnPromotionProducts(
inventoryProducts,
orderProducts
);
expect(filteredPromotionProducts).toEqual([
{ name: "사이다", price: "1000", quantity: 8, promotion: "탄산2+1" },
{ name: "감자칩", price: "1500", quantity: 5, promotion: "반짝할인" },
]);
});
test("프로모션 제품 유효성 - 프로모션이 있는지 + 날짜가 유효한지", async () => {
InventorySetupController();
const inventoryProducts = Inventory.getProducts();
const inventoryPromotions = Inventory.getPromotions();
const orderItem = "[사이다-1],[감자칩-3]";
const order = new Order(orderItem);
let orderProducts = order.getOrderProducts();
const filteredPromotionProducts = returnPromotionProducts(
inventoryProducts,
orderProducts
);
const isPromotionValid = validateOrderPromotion(
filteredPromotionProducts,
inventoryPromotions
);
expect(isPromotionValid).toBe(true);
});
});
이번 과제에서는 promotion이 킥이었다.!! 그래서 promotion에 대한 테스트도 작성을 했다. 우선, promotion과 관련되 로직들은 utils 함수로 따로 뺏었다. 그래서 프로모션만 따로 가공해주는 returnPromotionProducts
메소드를 테스트해보았고, 프로모션 제품 유효성을 테스트하는 validateOrderPromotion
메소드도 테스트를 진행했다.
5. membership 테스트
import { membershipDiscount } from "../src/utils/membership/membershipDiscount";
// 테스트 코드
describe("멤버십 컨트롤러 테스트", () => {
test("프로모션 아닌 상품 멤버십 할인 잘 되는지", async () => {
const orderItem = [
{ name: "사이다", number: 7, promotion: null, price: "1000" },
{ name: "감자칩", number: 2, promotion: null, price: "1500" },
];
const result = membershipDiscount(orderItem);
expect(result).toEqual(3000);
});
test("프로모션 상품들 멤버십 할인 x", async () => {
const orderItem = [
{ name: "사이다", number: 7, promotion: "탄산2+1", price: "1000" },
{ name: "감자칩", number: 2, promotion: "탄산2+1", price: "1500" },
];
const result = membershipDiscount(orderItem);
expect(result).toEqual(0);
});
});
멤버십 테스트는 현재 프로모션을 제외한 상품들만 할인이 잘들어가는지와 30% 계산이 잘 되는지 테스트를 진행했다.
6. Receipt 테스트
import Receipt from "../src/models/Receipt";
// 테스트 코드
describe("영수증 테스트", () => {
test("총가격이 잘 출력되는지", async () => {
const orderItem = [
{
name: "사이다",
number: 7,
promotion: null,
price: "1000",
buy: null,
get: null,
},
{
name: "감자칩",
number: 2,
promotion: null,
price: "1500",
buy: null,
get: null,
},
];
const receipt = new Receipt(orderItem, 0);
const totalPrice = receipt.calculateTotalAmount();
expect(totalPrice).toEqual(10000);
});
test("프로모션 공짜 물건 출력 잘 되는지", async () => {
const orderItem = [
{
name: "사이다",
number: 3,
promotion: "탄산2+1",
price: "1000",
buy: 2,
get: 1,
},
{ name: "감자칩", number: 2, promotion: null, price: "1500" },
];
const receipt = new Receipt(orderItem, 0);
const getFreeItems = receipt.getFreeItems();
expect(getFreeItems).toEqual([
{ name: "사이다", number: 1, price: "1000" },
]);
});
test("프로모션 공짜 물건 출력 잘 되는지", async () => {
const orderItem = [
{
name: "사이다",
number: 6,
promotion: "탄산2+1",
price: "1000",
buy: 2,
get: 1,
},
{ name: "감자칩", number: 2, promotion: null, price: "1500" },
];
const receipt = new Receipt(orderItem, 0);
const getFreeItems = receipt.getFreeItems();
expect(getFreeItems).toEqual([
{ name: "사이다", number: 2, price: "1000" },
]);
});
test("프로모션 할인 금액 출력", async () => {
const orderItem = [
{
name: "사이다",
number: 6,
promotion: "탄산2+1",
price: "1000",
buy: 2,
get: 1,
},
];
const receipt = new Receipt(orderItem, 0);
const promotionDiscountPrice = receipt.calculatePromotionDiscount();
expect(promotionDiscountPrice).toEqual(2000);
});
test("프로모션 할인 금액 출력", async () => {
const orderItem = [
{
name: "사이다",
number: 6,
promotion: "탄산2+1",
price: "1000",
buy: 2,
get: 1,
},
{ name: "감자칩", number: 2, promotion: null, price: "1500" },
];
const receipt = new Receipt(orderItem, 1000);
receipt.printReceipt();
});
});
Receipt
클래스 내부에서 동작하는 메소드들을 테스트하는 코드를 작성했다.
?? 근데 갑자기 저번주부터 잘 되던, 예제 테스트를 통과를 못하는 것이다! 아니 이게 뭐지? 처음에 5분 밖에 안남아서 당황을 했다. 그래서 기본으로 제공해준 ApplicationTest.js
파일과 마크다운 파일이 충돌이 나는 건 줄 알고, 빠르게 마크다운 파일을 수정해서 커밋을 다시 올렸다..(이렇게 하니까, 내가 기존에 테스트랍시고 적었던 테스트들이 다 오류가 났다.. 하지만 이 예제 테스트를 먼저 통과하는게 우선이라고 해서 빠르게 커밋을 수정해서 올럈다)
하지만, 결과는 똑같이 실패였다.
그래서 난 그럼 그냥 이렇게 내고, 다시 내 테스트 코드 잘 돌아가던거라도 되돌리자 하고, 커밋 내역을 revert했다! 근데 예제테스트로 올라간 커밋이 변경이 안되는 것이다,, (12시가 넘은 것이었다,,)
그래서 현재 우테코 지원페이지에 올라간 커밋은 최신 커밋이고, 내 깃헙 레포에는 최신 커밋이 없다.(revert를 했기 때문에,,)
이거 잘못되진 않겠지,,,??ㅜ🥲
이번 4주차 과제를 진행하면서 정말 내가 많이 부족하다는 걸 느꼈다. TDD의 중요성을 알고 적용해보려고 열심히 노력했는데, 생각보다 쉽지 않았고 코드가 조금만 복잡해져도 고려해야 할 게 정말 많다는 걸 깨달았다. 아쉬운 점도 많지만, 그만큼 배운 점도 많았던 시간이었다. 특히 MVC 패턴을 적용해보고, 테스트 코드 작성에도 도전해볼 수 있었던 게 큰 경험이었다. 이전에는 스파게티 코드로 짰었는데, 이번에는 최대한 함수와 파일을 분리해서 가독성을 높이려고 노력했고, 덕분에 코드를 체계적으로 작성하는 방법을 많이 배운 것 같다.
1~3주차를 통틀어서 이번 4주차가 난이도가 가장 높았고, 과제 명세도 정말 길었다. 하지만 그만큼 가장 도움이 많이 된 주차였다고 생각한다.
우테코 프리코스 4주차까지 완주를 하긴했다. 처음 목표는 단순히 빠르게 과제를 제출하는 게 아니라, 매일 고민하고, 어떻게 구현할지 방법을 탐구하고 시도하는 것이었는데 이 목표는 잘 지켰던 것 같아 뿌듯하긴하다. 그리고 덕분에 Jest, TDD, 클래스와 객체지향 설계, MVC 패턴 등 평소 잘 몰랐던 개념들을 많이 배울 수 있었다!!
이번 프리코스를 진행하는 기간만큼은 스스로 많이 성장할 수 있었던 시간이었던 것 같고, 앞으로도 이런 과정을 통해 더 발전하고 싶다!