From a1fa4a215f5819c9bcc1855c089b005928781c75 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 04:08:56 +0900 Subject: [PATCH 01/48] docs(chapter7): add content for encapsulation chapter --- chapter07/README.md | 24 ++++++++++++++++++++++++ chapter07/src/__init__.py | 0 chapter07/src/case01/__init__.py | 0 chapter07/tests/__init__.py | 0 chapter07/tests/case01/__init__.py | 0 5 files changed, 24 insertions(+) create mode 100644 chapter07/README.md create mode 100644 chapter07/src/__init__.py create mode 100644 chapter07/src/case01/__init__.py create mode 100644 chapter07/tests/__init__.py create mode 100644 chapter07/tests/case01/__init__.py diff --git a/chapter07/README.md b/chapter07/README.md new file mode 100644 index 0000000..3fe19df --- /dev/null +++ b/chapter07/README.md @@ -0,0 +1,24 @@ +# 7장 - 캡슐화 + +모듈 분리의 가장 중요한 기준은 비밀을 잘 숨기는 것. + +데이터 구조는 아래 기법으로 숨길 수 있다: +- 레코드 캡슐화하기(7.1절) +- 컬렉션 캡슐화하기(7.2절) +- 기본형을 객체로 바꾸기(7.3절) + +클래스에서는 임시변수 처리가 골치아픈데 이거는 임시변수를 질의로 바꾸기(7.4절)로 해결 + +클래스는 정보를 숨기는 용도로 설계되었으므로, 이를 위한 기법을 소개한다. +- 여러 함수를 클래스로 묶기(6.9절) +- 추출하기/인라인하기의 클래스 버전 소개 + - 클래스 추출하기(7.5절) + - 클래스 인라인하기(7.6절) + +클래스는 내부 정보 뿐 아니라 클래스간 연결관계를 숨기는 데도 유용하다. +- 위임 숨기기(7.7절) +- 중개자 제거하기(7.8절) + +클래스 뿐 아니라 함수도 구현을 캡슐화하는 개념이다. 알고리즘을 통째로 바꾸기 위해선 +- 함수 추출하기(6.1절)로 알고리즘을 함수에 담고 +- 알고리즘 교체하기(7.9절)을 적용 diff --git a/chapter07/src/__init__.py b/chapter07/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case01/__init__.py b/chapter07/src/case01/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/__init__.py b/chapter07/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case01/__init__.py b/chapter07/tests/case01/__init__.py new file mode 100644 index 0000000..e69de29 From 747a718ed372d6952b3d4ff3777aab96bb2a5d16 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 12:42:31 +0900 Subject: [PATCH 02/48] docs(TEMPLATES): add before and after code sections for clarity --- TEMPLATES.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/TEMPLATES.md b/TEMPLATES.md index 9baf7db..41b0f73 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -2,6 +2,18 @@ ## 개요 +Before + +```python + +``` + +After + +```python + +``` + ## 배경 ## 절차 From 79d61b716b3bcfb0e291abd806e5fdf14da597d3 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 22:22:19 +0900 Subject: [PATCH 03/48] feat(chapter7): add encapsulation example and background information --- chapter07/README.md | 19 ++++++ chapter07/src/case01/README.md | 97 +++++++++++++++++++++++++++ chapter07/src/case01/case01.py | 6 ++ chapter07/tests/case01/test_case01.py | 0 4 files changed, 122 insertions(+) create mode 100644 chapter07/src/case01/README.md create mode 100644 chapter07/src/case01/case01.py create mode 100644 chapter07/tests/case01/test_case01.py diff --git a/chapter07/README.md b/chapter07/README.md index 3fe19df..ea92cad 100644 --- a/chapter07/README.md +++ b/chapter07/README.md @@ -22,3 +22,22 @@ 클래스 뿐 아니라 함수도 구현을 캡슐화하는 개념이다. 알고리즘을 통째로 바꾸기 위해선 - 함수 추출하기(6.1절)로 알고리즘을 함수에 담고 - 알고리즘 교체하기(7.9절)을 적용 + +# Appendix I. 레코드가 뭐지? + +데이터 중심으로 값을 담고있는 객체. 비즈니스 로직이 없고, 데이터를 담기만 한다. + +데이터 레코드는 그냥 값만 담고있다가, 클래스화를 통해서 강력한 객체를 발전시킬 수 있다. 이미 6장에서 겪어봤듯. + +책에서는 이런 개념들로 리팩터하기를 권한다. + +1. "레코드 캡슐화하기(Encapsulate Record)" - 단순 데이터 구조를 객체로 변환하여 데이터 접근을 제어하는 방법 +2. "컬렉션 캡슐화하기(Encapsulate Collection)" - 컬렉션 데이터를 직접 조작하는 대신 객체를 통해 안전하게 조작하도록 변경 +3. "클래스 추출하기(Extract Class)" - 관련 데이터와 동작을 새로운 클래스로 분리 +4. "함수 옮기기(Move Function)" - 로직을 적절한 도메인 객체로 이동 + +이러면 아래 이점을 얻을 수 있다: + +- 데이터는 필요할 때만 읽고/쓰게 "캡슐화" +- 비즈니스 로직은 진짜 책임이 있는 "도메인 객체"로 이동 +- 데이터 레코드는 순수 데이터 보관소로 유지하고, 로직은 도메인 모델이 책임지게 diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md new file mode 100644 index 0000000..dea2125 --- /dev/null +++ b/chapter07/src/case01/README.md @@ -0,0 +1,97 @@ +# 7.1 레코드 캡슐화하기 + +_Encapsulate Record_ + +## 개요 + +Before + +```python +organization = { + "name": "Acme Gooseberries", + "country": "GB", +} +``` + +After + +```python +class Organization: + def __init__( + self, + name: str, + country: str, + ): + self._name = name + self._country = country + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + @property + def country(self) -> str: + return self._country + + @country.setter + def country(self, value: str) -> None: + self._country = value +``` + +파이썬이면 엄밀히는 접근제한자가 없으니, 데이터의 성격에 따라 이를 책임있게 사용한다. + +```python +class Organization: + def __init__( + self, + name: str, + country: str, + ): + self.name = name + self.country = country + +>>> org = Organization(name="test", country="KR") +>>> org.name +test +``` + +## 배경 + +데이터를 넣을 때 이런 유의미한 값을 넣을 수 있다. 매우 직관적으로 넣을 수 있지만, 단순하면 세세함을 챙기기 어렵다. +특히나 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 가르기가 너무 어렵다. + +마틴 파울러는 이 때문에 가변 데이터(_mutable_)를 레코드보다 객체를 더 선호한다. + +객체를 쓰면 계산으로 얻을 수 있는 값, 그렇지 않은 값을 각각의 메소드로 줄 수도 있고, 계산되었는지 신경 쓸 필요도 없다. +캡슐화를 하면 이름 변경에도 유리하다. + +가변 데이터일 때는 객체면 좋고, 불변(_immutable_) 데이터면 값을 모두 구해서 저장하고, 이름을 바꿀 때는 필드를 복제한다. + +이런 레코드 구조는 두 가지로 갈린다: +1. 필드 이름을 노출하는 경우 +2. (필드를 외부로부터 숨겨서) 내가 원하는 이름을 쓰는 경우 + - hash, map, hashmap, dictionary(!), associative array 등의 이름 + +이런 nested list, hashmap은 json, xml로 직렬화 가능하다. 이런 구조도 캡슐화할 수 있다. + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 레코드를 담은 변수를 캡슐화(6.6절)한다 +2. 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다. 이 클래스에 원본 레코드를 반환하는 접근자도 정의하고, 변수를 캡슐화하는 함수들이 이 접근자를 사용하도록 수정한다. +3. 테스트한다 +4. 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다 +5. 레코드를 반환하는 예전 함수를 사용하는 코드를 4에서 만든 새 함수로 갈아끼운다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없으면 추가한다. 한 부분을 바꿀 때마다 테스트한다.
+→ 중첩된 구조처럼 복잡한 레코드면 데이터 갱신하는 클라이언트에 주의해서 살펴본다. 클라이언트가 데이터를 읽기만 한다면 데이터의 복제본이나 읽기 전용 프록시를 반환할 건지 고려 필요 +6. 클래스에서 원본 데이터를 반환하는 접근자와 (1에서 검색하기 쉬운 이름을 붙여둔) 원본 레코드를 반환하는 함수를 제거한다 +7. 테스트한다 +8. 레코드의 필드에도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슈로하하기(7.2절)를 재귀적으로 적용한다. + +## 예시 (1) - 간단한 레코드 캡슐화하기 + + diff --git a/chapter07/src/case01/case01.py b/chapter07/src/case01/case01.py new file mode 100644 index 0000000..d79b01c --- /dev/null +++ b/chapter07/src/case01/case01.py @@ -0,0 +1,6 @@ +organization = {"name": "Acme Gooseberries", "country": "GB"} + + +# client 1 +result = f"

{organization['name']}

" +organization["name"] = "new name" diff --git a/chapter07/tests/case01/test_case01.py b/chapter07/tests/case01/test_case01.py new file mode 100644 index 0000000..e69de29 From fb38e282e65613245fb88f6c172887d570fc4003 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 22:23:15 +0900 Subject: [PATCH 04/48] refactor(chapter7): encapsulate organization data retrieval in a function --- chapter07/src/case01/case01.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/chapter07/src/case01/case01.py b/chapter07/src/case01/case01.py index d79b01c..fe80f5d 100644 --- a/chapter07/src/case01/case01.py +++ b/chapter07/src/case01/case01.py @@ -1,6 +1,11 @@ organization = {"name": "Acme Gooseberries", "country": "GB"} +# 임시로 이름 붙인 것 +def get_raw_data_of_organization(): + return organization + + # client 1 -result = f"

{organization['name']}

" -organization["name"] = "new name" +result = f"

{get_raw_data_of_organization()['name']}

" +get_raw_data_of_organization()["name"] = "new name" From d9f0aed76d8ef866453f1d06a349d326423300c7 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 22:33:14 +0900 Subject: [PATCH 05/48] refactor(chapter7): encapsulate organization data in a class and update data retrieval methods --- chapter07/src/case01/README.md | 4 ++++ chapter07/src/case01/case01.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md index dea2125..6000e94 100644 --- a/chapter07/src/case01/README.md +++ b/chapter07/src/case01/README.md @@ -94,4 +94,8 @@ test ## 예시 (1) - 간단한 레코드 캡슐화하기 +상수 캡슐화는 여기서한다 (be25259b2cc25e989f5c6ee983fdad3338fc11d3) +이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다. +- 레코드를 클래스로 바꾸기 +- 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다 \ No newline at end of file diff --git a/chapter07/src/case01/case01.py b/chapter07/src/case01/case01.py index fe80f5d..764c88c 100644 --- a/chapter07/src/case01/case01.py +++ b/chapter07/src/case01/case01.py @@ -1,11 +1,18 @@ -organization = {"name": "Acme Gooseberries", "country": "GB"} +class Organization: + def __init__(self, data): + self.data = data # 임시로 이름 붙인 것 def get_raw_data_of_organization(): + return organization.data + + +def get_organization(): return organization # client 1 -result = f"

{get_raw_data_of_organization()['name']}

" -get_raw_data_of_organization()["name"] = "new name" +organization = Organization({"name": "Acme Gooseberries", "country": "GB"}) +result = f"

{get_organization().data['name']}

" +get_organization().data["name"] = "new name" From b0e1643fa61f538872b175ae7ed2ceb6421fb91f Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 22:38:09 +0900 Subject: [PATCH 06/48] refactor(chapter7): improve organization class by adding attributes and removing unnecessary functions --- chapter07/src/case01/case01.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/chapter07/src/case01/case01.py b/chapter07/src/case01/case01.py index 764c88c..f8e55d2 100644 --- a/chapter07/src/case01/case01.py +++ b/chapter07/src/case01/case01.py @@ -1,18 +1,10 @@ class Organization: def __init__(self, data): - self.data = data - - -# 임시로 이름 붙인 것 -def get_raw_data_of_organization(): - return organization.data - - -def get_organization(): - return organization + self.name = data["name"] + self.country = data["country"] # client 1 organization = Organization({"name": "Acme Gooseberries", "country": "GB"}) -result = f"

{get_organization().data['name']}

" -get_organization().data["name"] = "new name" +result = f"

{organization.name}

" +organization.name = "new name" From 1bf5149e88e76fdaec401c8a16d6278e14abd727 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Mon, 3 Mar 2025 22:39:17 +0900 Subject: [PATCH 07/48] docs(chapter7): update encapsulation section for clarity and completeness --- chapter07/src/case01/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md index 6000e94..d4851f0 100644 --- a/chapter07/src/case01/README.md +++ b/chapter07/src/case01/README.md @@ -96,6 +96,8 @@ test 상수 캡슐화는 여기서한다 (be25259b2cc25e989f5c6ee983fdad3338fc11d3) -이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다. +이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다 (d9f0aed76d8ef866453f1d06a349d326423300c7) - 레코드를 클래스로 바꾸기 -- 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다 \ No newline at end of file +- 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다 + +`data`를 풀어서 쓸 수도 있다 (b0e1643fa61f538872b175ae7ed2ceb6421fb91f) From 82ccafd4be7eafb1d27850362138101e7a2e3666 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 00:14:23 +0900 Subject: [PATCH 08/48] refactor(chapter7): rename files and add examples for customer data encapsulation --- chapter07/src/case01/README.md | 4 +++- chapter07/src/case01/{case01.py => case01_1.py} | 0 chapter07/src/case01/case01_2.py | 15 +++++++++++++++ .../case01/{test_case01.py => test_case01_2.py} | 0 4 files changed, 18 insertions(+), 1 deletion(-) rename chapter07/src/case01/{case01.py => case01_1.py} (100%) create mode 100644 chapter07/src/case01/case01_2.py rename chapter07/tests/case01/{test_case01.py => test_case01_2.py} (100%) diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md index d4851f0..88f7136 100644 --- a/chapter07/src/case01/README.md +++ b/chapter07/src/case01/README.md @@ -94,10 +94,12 @@ test ## 예시 (1) - 간단한 레코드 캡슐화하기 -상수 캡슐화는 여기서한다 (be25259b2cc25e989f5c6ee983fdad3338fc11d3) +상수 캡슐화는 여기서한다 (fb38e282e65613245fb88f6c172887d570fc4003) 이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다 (d9f0aed76d8ef866453f1d06a349d326423300c7) - 레코드를 클래스로 바꾸기 - 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다 `data`를 풀어서 쓸 수도 있다 (b0e1643fa61f538872b175ae7ed2ceb6421fb91f) + +## 예시 (2) - 중첩된 레코드 캡슐화하기 diff --git a/chapter07/src/case01/case01.py b/chapter07/src/case01/case01_1.py similarity index 100% rename from chapter07/src/case01/case01.py rename to chapter07/src/case01/case01_1.py diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py new file mode 100644 index 0000000..1f533f3 --- /dev/null +++ b/chapter07/src/case01/case01_2.py @@ -0,0 +1,15 @@ +customer_data = {} + +# example (1) - write +customer_data["customer_id"]["usages"]["year"]["month"] = amount + + +# example (2) - read +def compare_usage(customer_id, later_year, month): + later = customer_data[customer_id]["usages"][later_year][month] + earlier = customer_data[customer_id]["usages"][later_year - 1][month] + + return { + "later_amount": later, + "change": later - earlier, + } diff --git a/chapter07/tests/case01/test_case01.py b/chapter07/tests/case01/test_case01_2.py similarity index 100% rename from chapter07/tests/case01/test_case01.py rename to chapter07/tests/case01/test_case01_2.py From 97019552ba0f99f9a4217095cfdf3868db33225a Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 00:28:52 +0900 Subject: [PATCH 09/48] refactor(chapter7): enhance customer data structure and add usage tracking functions --- chapter07/src/case01/case01_2.py | 30 ++++++++++++++++++++++--- chapter07/tests/case01/test_case01_2.py | 16 +++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py index 1f533f3..7dea232 100644 --- a/chapter07/src/case01/case01_2.py +++ b/chapter07/src/case01/case01_2.py @@ -1,13 +1,37 @@ -customer_data = {} +customer_data = { + "1920": { + "name": "martin", + "id": "1920", + "usages": { + "2016": { + "1": 50, + "2": 55, + # remaining months of the year + }, + "2015": { + "1": 70, + "2": 63, + # remaining months of the year + }, + }, + }, + "38673": { + "name": "neal", + "id": "38673", + # more customers in a similar form + }, +} + # example (1) - write -customer_data["customer_id"]["usages"]["year"]["month"] = amount +def set_usage(customer_id, year, month, amount): + customer_data[customer_id]["usages"][year][month] = amount # example (2) - read def compare_usage(customer_id, later_year, month): later = customer_data[customer_id]["usages"][later_year][month] - earlier = customer_data[customer_id]["usages"][later_year - 1][month] + earlier = customer_data[customer_id]["usages"][f"{int(later_year) - 1}"][month] return { "later_amount": later, diff --git a/chapter07/tests/case01/test_case01_2.py b/chapter07/tests/case01/test_case01_2.py index e69de29..db66966 100644 --- a/chapter07/tests/case01/test_case01_2.py +++ b/chapter07/tests/case01/test_case01_2.py @@ -0,0 +1,16 @@ +from src.case01.case01_2 import set_usage, compare_usage, customer_data + + +def test_set_usage(): + set_usage("1920", "2016", "3", 60) + assert customer_data["1920"]["usages"]["2016"]["3"] == 60 + + +def test_compare_usage(): + result = compare_usage("1920", "2016", "1") + assert result["later_amount"] == 50 + assert result["change"] == -20 + + result = compare_usage("1920", "2016", "2") + assert result["later_amount"] == 55 + assert result["change"] == -8 From 256f2c669ac29f0a562d113b73c93662962b3f3b Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 00:30:25 +0900 Subject: [PATCH 10/48] refactor(chapter7): encapsulate customer data handling in a class and update access methods --- chapter07/src/case01/case01_2.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py index 7dea232..5f72621 100644 --- a/chapter07/src/case01/case01_2.py +++ b/chapter07/src/case01/case01_2.py @@ -1,3 +1,20 @@ +class CustomerData: + def __init__(self, data): + self._data = data + + +def get_customer_data(): + return customer_data + + +def get_raw_data_of_customers(): + return customer_data._data + + +def set_raw_data_of_customers(arg): + customer_data = CustomerData(arg) + + customer_data = { "1920": { "name": "martin", @@ -25,13 +42,15 @@ # example (1) - write def set_usage(customer_id, year, month, amount): - customer_data[customer_id]["usages"][year][month] = amount + get_customer_data()[customer_id]["usages"][year][month] = amount # example (2) - read def compare_usage(customer_id, later_year, month): - later = customer_data[customer_id]["usages"][later_year][month] - earlier = customer_data[customer_id]["usages"][f"{int(later_year) - 1}"][month] + later = get_customer_data()[customer_id]["usages"][later_year][month] + earlier = get_customer_data()[customer_id]["usages"][f"{int(later_year) - 1}"][ + month + ] return { "later_amount": later, From 4e13fabe8641fe13ee5b9af7a939510f13ca8762 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 00:33:47 +0900 Subject: [PATCH 11/48] refactor(chapter7): move set_usage function into CustomerData class for better encapsulation --- chapter07/src/case01/case01_2.py | 9 ++++----- chapter07/tests/case01/test_case01_2.py | 9 +++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py index 5f72621..59bb63d 100644 --- a/chapter07/src/case01/case01_2.py +++ b/chapter07/src/case01/case01_2.py @@ -2,6 +2,10 @@ class CustomerData: def __init__(self, data): self._data = data + # example (1) - write + def set_usage(self, customer_id, year, month, amount): + get_customer_data()[customer_id]["usages"][year][month] = amount + def get_customer_data(): return customer_data @@ -40,11 +44,6 @@ def set_raw_data_of_customers(arg): } -# example (1) - write -def set_usage(customer_id, year, month, amount): - get_customer_data()[customer_id]["usages"][year][month] = amount - - # example (2) - read def compare_usage(customer_id, later_year, month): later = get_customer_data()[customer_id]["usages"][later_year][month] diff --git a/chapter07/tests/case01/test_case01_2.py b/chapter07/tests/case01/test_case01_2.py index db66966..7e00e19 100644 --- a/chapter07/tests/case01/test_case01_2.py +++ b/chapter07/tests/case01/test_case01_2.py @@ -1,8 +1,13 @@ -from src.case01.case01_2 import set_usage, compare_usage, customer_data +from src.case01.case01_2 import ( + compare_usage, + customer_data, + CustomerData, +) def test_set_usage(): - set_usage("1920", "2016", "3", 60) + data = CustomerData(customer_data) + data.set_usage("1920", "2016", "3", 60) assert customer_data["1920"]["usages"]["2016"]["3"] == 60 From daf79c75983ea5aff448f839c8bab0a7b5a57cb6 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 00:48:16 +0900 Subject: [PATCH 12/48] refactor(chapter7): improve CustomerData class by encapsulating data access and adding usage methods --- chapter07/src/case01/case01_2.py | 26 +++++++++++++++++-------- chapter07/tests/case01/test_case01_2.py | 5 +++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py index 59bb63d..a406746 100644 --- a/chapter07/src/case01/case01_2.py +++ b/chapter07/src/case01/case01_2.py @@ -1,25 +1,34 @@ +from copy import deepcopy + + class CustomerData: def __init__(self, data): self._data = data # example (1) - write def set_usage(self, customer_id, year, month, amount): - get_customer_data()[customer_id]["usages"][year][month] = amount + self._data[customer_id]["usages"][year][month] = amount + + def usage(self, customer_id, year, month): + return self._data[customer_id]["usages"][year][month] + + def get_raw_data(self): + return deepcopy(self._data) def get_customer_data(): - return customer_data + return CustomerData(raw_data) def get_raw_data_of_customers(): - return customer_data._data + return customer_data.get_raw_data() def set_raw_data_of_customers(arg): customer_data = CustomerData(arg) -customer_data = { +raw_data = { "1920": { "name": "martin", "id": "1920", @@ -44,12 +53,13 @@ def set_raw_data_of_customers(arg): } +customer_data = CustomerData(raw_data) + + # example (2) - read def compare_usage(customer_id, later_year, month): - later = get_customer_data()[customer_id]["usages"][later_year][month] - earlier = get_customer_data()[customer_id]["usages"][f"{int(later_year) - 1}"][ - month - ] + later = get_customer_data().usage(customer_id, later_year, month) + earlier = get_customer_data().usage(customer_id, f"{int(later_year) - 1}", month) return { "later_amount": later, diff --git a/chapter07/tests/case01/test_case01_2.py b/chapter07/tests/case01/test_case01_2.py index 7e00e19..2e25775 100644 --- a/chapter07/tests/case01/test_case01_2.py +++ b/chapter07/tests/case01/test_case01_2.py @@ -2,13 +2,14 @@ compare_usage, customer_data, CustomerData, + raw_data, ) def test_set_usage(): - data = CustomerData(customer_data) + data = CustomerData(raw_data) data.set_usage("1920", "2016", "3", 60) - assert customer_data["1920"]["usages"]["2016"]["3"] == 60 + assert data._data["1920"]["usages"]["2016"]["3"] == 60 def test_compare_usage(): From aff4cafe70bb35243ed9c0628802147930b35a09 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 01:41:37 +0900 Subject: [PATCH 13/48] refactor(chapter7): add compare_usage method to CustomerData class and update usage handling --- chapter07/src/case01/README.md | 4 +++ chapter07/src/case01/case01_2.py | 41 +++++++++++++++---------- chapter07/tests/case01/test_case01_2.py | 41 +++++++++++++++++++++---- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md index 88f7136..8c78897 100644 --- a/chapter07/src/case01/README.md +++ b/chapter07/src/case01/README.md @@ -103,3 +103,7 @@ test `data`를 풀어서 쓸 수도 있다 (b0e1643fa61f538872b175ae7ed2ceb6421fb91f) ## 예시 (2) - 중첩된 레코드 캡슐화하기 + +...?? + +이게 캡슐화된거라고? \ No newline at end of file diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py index a406746..d6212c3 100644 --- a/chapter07/src/case01/case01_2.py +++ b/chapter07/src/case01/case01_2.py @@ -5,29 +5,44 @@ class CustomerData: def __init__(self, data): self._data = data - # example (1) - write def set_usage(self, customer_id, year, month, amount): self._data[customer_id]["usages"][year][month] = amount def usage(self, customer_id, year, month): return self._data[customer_id]["usages"][year][month] + def compare_usage(self, customer_id, later_year, month): + later = self.usage(customer_id, later_year, month) + earlier_year = str(int(later_year) - 1) + earlier = self.usage(customer_id, earlier_year, month) + + return { + "later_amount": later, + "change": later - earlier, + } + def get_raw_data(self): return deepcopy(self._data) +# 전역 인스턴스 +customer_data = None + + +def initialize_data(data): + global customer_data + customer_data = CustomerData(data) + + def get_customer_data(): - return CustomerData(raw_data) + return customer_data def get_raw_data_of_customers(): return customer_data.get_raw_data() -def set_raw_data_of_customers(arg): - customer_data = CustomerData(arg) - - +# 예제 데이터 초기화 raw_data = { "1920": { "name": "martin", @@ -52,16 +67,10 @@ def set_raw_data_of_customers(arg): }, } +# 데이터 초기화 +initialize_data(raw_data) -customer_data = CustomerData(raw_data) - -# example (2) - read +# 이제 compare_usage는 CustomerData 클래스의 메서드를 사용합니다 def compare_usage(customer_id, later_year, month): - later = get_customer_data().usage(customer_id, later_year, month) - earlier = get_customer_data().usage(customer_id, f"{int(later_year) - 1}", month) - - return { - "later_amount": later, - "change": later - earlier, - } + return get_customer_data().compare_usage(customer_id, later_year, month) diff --git a/chapter07/tests/case01/test_case01_2.py b/chapter07/tests/case01/test_case01_2.py index 2e25775..d5e7d37 100644 --- a/chapter07/tests/case01/test_case01_2.py +++ b/chapter07/tests/case01/test_case01_2.py @@ -1,18 +1,39 @@ +from copy import deepcopy + +import pytest from src.case01.case01_2 import ( compare_usage, - customer_data, + get_customer_data, + initialize_data, CustomerData, raw_data, ) -def test_set_usage(): - data = CustomerData(raw_data) - data.set_usage("1920", "2016", "3", 60) - assert data._data["1920"]["usages"]["2016"]["3"] == 60 +# 각 테스트 전에 깨끗한 데이터로 시작 +@pytest.fixture +def setup_data(): + test_data = deepcopy(raw_data) + initialize_data(test_data) + return test_data + + +def test_set_usage(setup_data): + # CustomerData 객체의 메서드 테스트 + customer_data = get_customer_data() + customer_data.set_usage("1920", "2016", "3", 60) + assert customer_data.usage("1920", "2016", "3") == 60 + + +def test_usage_method(setup_data): + # usage 메서드 테스트 + customer_data = get_customer_data() + assert customer_data.usage("1920", "2016", "1") == 50 + assert customer_data.usage("1920", "2015", "1") == 70 -def test_compare_usage(): +def test_compare_usage(setup_data): + # 전역 함수 테스트 result = compare_usage("1920", "2016", "1") assert result["later_amount"] == 50 assert result["change"] == -20 @@ -20,3 +41,11 @@ def test_compare_usage(): result = compare_usage("1920", "2016", "2") assert result["later_amount"] == 55 assert result["change"] == -8 + + +def test_compare_usage_method(setup_data): + # 클래스 메서드 직접 테스트 + customer_data = get_customer_data() + result = customer_data.compare_usage("1920", "2016", "1") + assert result["later_amount"] == 50 + assert result["change"] == -20 From 7c20d9386e1ac551f41b7b68468dcfac6940ca52 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Tue, 4 Mar 2025 01:41:49 +0900 Subject: [PATCH 14/48] feat(dependencies): add coverage and pytest-cov packages for improved test coverage --- poetry.lock | 95 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 0eaddda..a08a4be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -109,6 +109,81 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.6.12" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, + {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, + {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, + {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, + {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, + {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, + {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, + {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, + {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, + {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, + {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, + {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, + {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, + {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, + {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, + {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -204,6 +279,24 @@ pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "6.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -254,4 +347,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "364ccf2edcc23ac4f7a3fe587a4542e6bb223dce0899a2d7ba28349c6b96d45b" +content-hash = "a1becce150de9193a0cf3c78baa634068170d8a2634b48175a35a272e52cd68b" diff --git a/pyproject.toml b/pyproject.toml index 1d00083..3c1cb22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ arrow = "^1.3.0" [tool.poetry.group.test.dependencies] pytest = "^8.3.4" beautifulsoup4 = "^4.12.3" +pytest-cov = "^6.0.0" [tool.poetry.group.linter.dependencies] From 96ffe9a509292654b016d5065bacd7226d28dfa5 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 00:02:27 +0900 Subject: [PATCH 15/48] feat(chapter7): add Person and Course classes with encapsulated course management --- chapter07/src/case02/README.md | 66 +++++++++++++++++++++++++++ chapter07/src/case02/__init__.py | 0 chapter07/src/case02/case02.py | 33 ++++++++++++++ chapter07/tests/case02/__init__.py | 0 chapter07/tests/case02/test_case02.py | 37 +++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 chapter07/src/case02/README.md create mode 100644 chapter07/src/case02/__init__.py create mode 100644 chapter07/src/case02/case02.py create mode 100644 chapter07/tests/case02/__init__.py create mode 100644 chapter07/tests/case02/test_case02.py diff --git a/chapter07/src/case02/README.md b/chapter07/src/case02/README.md new file mode 100644 index 0000000..4166154 --- /dev/null +++ b/chapter07/src/case02/README.md @@ -0,0 +1,66 @@ +# 7.2 컬렉션 캡슐화하기 + +_Encapsulate Collection_ + +## 개요 + +Before + +```python +class Person: + def __init__(self, courses): + self._courses = courses + + @property + def courses(self): + return self._courses + + @courses.setter + def courses(self, a_list): + self._courses = a_list + +``` + +After + +```python +import copy # for shallow copy + +class Person: + def __init__(self, courses): + self._courses = courses + + @property + def courses(self): + return copy.copy(self._courses) + + def add_course(self, a_course): ... + def remove_course(self, a_course): ... +``` + +## 배경 + +가변 데이터를 캡슐화해서 원치않는 변경을 허락하는걸 막는다. getter가 컬렉션 자체를 반환하면 눈치채지 못하는 사이에 컬렉션 원소가 수정될 수 있다. + +파울러는 `add_course`, `remove_course` 같은 컬렉션 수정을 하는 메소드를 통해서 값을 수정하도록 한다. + +핵심은, 원본 모듈 밖에서 컬렉션을 수정하지 않는 습관이 필요하다. 이런 건 수단으로 강제하는 것이 좋다. + +1. 컬렉션 필드 접근을 메소드로 감싸기 +2. 복사본을 주기 + +아무튼 코드베이스에서는 일관성을 지니게 하는 것이 핵심이다. 한 프로젝트는 반드시 하나의 방식을 공유하자. + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 컬렉션을 캡슐화부터 한다(6.6절) +2. 컬렉션에 원소를 추가/제거 하는 함수를 추가한다
+→ 컬렉션 자체를 통째로 바꾸는 세터는 제거한다(11.7절). 세터를 제거할 수 없다면 인수로 받은 컬렉션을 복제해 저장하게 만든다 +3. 정적 검사를 수행한다 +4. 컬렉션을 참조하는 부분을 모두 찾는다. 컬렉션의 변경자를 호출하는 코드가 모두 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다. 하나씩 바꿀 때마다 테스트한다. +5. 컬렉션 게터를 수정해서 원본 내용을 수정할 수 없는 읽기전용 프록시나 복제본을 반환하게 한다. +6. 테스트한다. + +## 예시 diff --git a/chapter07/src/case02/__init__.py b/chapter07/src/case02/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case02/case02.py b/chapter07/src/case02/case02.py new file mode 100644 index 0000000..ca1af18 --- /dev/null +++ b/chapter07/src/case02/case02.py @@ -0,0 +1,33 @@ +class Person: + def __init__(self, name): + self._name = name + self._courses = [] + + @property + def name(self): + return self._name + + @property + def courses(self): + return self._courses + + @courses.setter + def courses(self, course_list): + self._courses = course_list + + def get_num_advanced_courses(self): + return len([course for course in self.courses if course.is_advanced]) + + +class Course: + def __init__(self, name, is_advanced): + self._name = name + self._is_advanced = is_advanced + + @property + def name(self): + return self._name + + @property + def is_advanced(self): + return self._is_advanced diff --git a/chapter07/tests/case02/__init__.py b/chapter07/tests/case02/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case02/test_case02.py b/chapter07/tests/case02/test_case02.py new file mode 100644 index 0000000..3235020 --- /dev/null +++ b/chapter07/tests/case02/test_case02.py @@ -0,0 +1,37 @@ +from src.case02.case02 import Person, Course + + +def test_person_creation(): + person = Person("John") + assert person.name == "John" + assert person.courses == [] + + +def test_course_creation(): + course = Course("Python Programming", True) + assert course.name == "Python Programming" + assert course.is_advanced == True + + +def test_adding_courses(): + person = Person("John") + basic_course = Course("Basic Programming", False) + advanced_course = Course("Advanced Python", True) + + person.courses = [basic_course, advanced_course] + assert len(person.courses) == 2 + assert person.courses[0].name == "Basic Programming" + assert person.courses[1].name == "Advanced Python" + + +def test_counting_advanced_courses(): + person = Person("John") + courses = [ + Course("Basic Programming", False), + Course("Advanced Python", True), + Course("Advanced AI", True), + Course("Web Basics", False), + ] + person.courses = courses + + assert person.get_num_advanced_courses() == 2 From 6b6f33275031dafde0778529d7c907b023f24c3c Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 00:15:37 +0900 Subject: [PATCH 16/48] feat(chapter7): enhance Person class with course management methods --- chapter07/src/case02/README.md | 2 ++ chapter07/src/case02/case02.py | 21 +++++++++++++++++---- chapter07/tests/case02/test_case02.py | 6 ++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/chapter07/src/case02/README.md b/chapter07/src/case02/README.md index 4166154..6d73c40 100644 --- a/chapter07/src/case02/README.md +++ b/chapter07/src/case02/README.md @@ -64,3 +64,5 @@ class Person: 6. 테스트한다. ## 예시 + +컬렉션 접근 제어를 막는게 캡슐화지, private으로 설정했다고 다가 아니다. \ No newline at end of file diff --git a/chapter07/src/case02/case02.py b/chapter07/src/case02/case02.py index ca1af18..69c150a 100644 --- a/chapter07/src/case02/case02.py +++ b/chapter07/src/case02/case02.py @@ -1,3 +1,6 @@ +import copy + + class Person: def __init__(self, name): self._name = name @@ -9,11 +12,21 @@ def name(self): @property def courses(self): - return self._courses + return copy.copy(self._courses) + + def add_course(self, course): + self._courses.append(course) + + def remove_course(self, course): + try: + self._courses.remove(course) + except ValueError: + raise ValueError("Course not found") - @courses.setter - def courses(self, course_list): - self._courses = course_list + # 가급적 안 쓰는 방안으로. + # @courses.setter + # def courses(self, course_list): + # self._courses = copy.copy(course_list) def get_num_advanced_courses(self): return len([course for course in self.courses if course.is_advanced]) diff --git a/chapter07/tests/case02/test_case02.py b/chapter07/tests/case02/test_case02.py index 3235020..3a80e30 100644 --- a/chapter07/tests/case02/test_case02.py +++ b/chapter07/tests/case02/test_case02.py @@ -18,7 +18,8 @@ def test_adding_courses(): basic_course = Course("Basic Programming", False) advanced_course = Course("Advanced Python", True) - person.courses = [basic_course, advanced_course] + for course in [basic_course, advanced_course]: + person.add_course(course) assert len(person.courses) == 2 assert person.courses[0].name == "Basic Programming" assert person.courses[1].name == "Advanced Python" @@ -32,6 +33,7 @@ def test_counting_advanced_courses(): Course("Advanced AI", True), Course("Web Basics", False), ] - person.courses = courses + for course in courses: + person.add_course(course) assert person.get_num_advanced_courses() == 2 From 14bd0fca0bb8ad6f04c02366b6b1358b237f86ab Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 00:38:59 +0900 Subject: [PATCH 17/48] feat(chapter7): add Order class and related tests for order management --- chapter07/src/case02/README.md | 2 +- chapter07/src/case03/README.md | 60 +++++++++++++++++++++++++++ chapter07/src/case03/__init__.py | 0 chapter07/src/case03/case03.py | 11 +++++ chapter07/tests/case03/__init__.py | 0 chapter07/tests/case03/test_case03.py | 49 ++++++++++++++++++++++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 chapter07/src/case03/README.md create mode 100644 chapter07/src/case03/__init__.py create mode 100644 chapter07/src/case03/case03.py create mode 100644 chapter07/tests/case03/__init__.py create mode 100644 chapter07/tests/case03/test_case03.py diff --git a/chapter07/src/case02/README.md b/chapter07/src/case02/README.md index 6d73c40..3981fc7 100644 --- a/chapter07/src/case02/README.md +++ b/chapter07/src/case02/README.md @@ -65,4 +65,4 @@ class Person: ## 예시 -컬렉션 접근 제어를 막는게 캡슐화지, private으로 설정했다고 다가 아니다. \ No newline at end of file +컬렉션 접근 제어를 막는게 캡슐화지, private으로 설정했다고 다가 아니다. diff --git a/chapter07/src/case03/README.md b/chapter07/src/case03/README.md new file mode 100644 index 0000000..38a52d5 --- /dev/null +++ b/chapter07/src/case03/README.md @@ -0,0 +1,60 @@ +# 7.3 기본형을 객체로 바꾸기 + +_Replace Primitive with Object_ + +## 개요 + +Before + +```python +# 기본형으로 쓰던 걸... +orders = list(filter(lambda o: o.priority == "high" or o.priority == "rush", orders)) +``` + +After + +```python +# ...객체화 +class Priority: + _values = ("low", "normal", "high", "rush") + + def __init__(self, value): + if value not in self._values: + raise ValueError(f"Invalid priority value: {value}") + self._value = value + + def higher_than(self, other): + return self._values.index(self._value) > self._values.index(other._value) + + @property + def value(self): + return self._value + + +orders = list(filter(lambda o: o.priority.higher_than(Priority("normal")), orders)) +``` + +## 배경 + +단순 정보가 복잡해지고 고도화된 정보 자신만이 수행하는 "동작"이 필요할 때가 올 수 있다. 이를 위해 그 데이터를 표현하는 전용 클래스를 정의한다. + +파울러는 그 시점을 '출력 이상의 기능을 제공'할 때로 보고 그 때부터 클래스화를 제안한다. + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 아직 변수를 캡슐화 하지 않았다면 캡슐화(6.6절) 한다. +2. 단순한 값 클래스(_value class_)를 만든다. 생성자는 기존 값을 인수로 받아 저장하고, 이 값을 반환하게 한다 +3. 정적 검사를 수행한다 +4. 값 클래스의 인스턴스를 새로 만들고 필드에 저장하도록 세터[^1]를 수정한다. 이미 있다면 필드의 타입을 바꾼다 +5. 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터[^2]를 수정한다 +6. 테스트한다 +7. 함수 이름을 바꾸면(6.5절) 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다
+→ 참조를 값으로 바꾸거나(9.4절), 값을 참조로 바꾸면(9.5절) 새로 만든 객체의 역할(값, 참조 객체)이 더 잘 드러나는지 검토한다. + +## 예시 + + +[^1]: 단계 1에서 변수를 캡슐화하며 만든 세터 +[^1]: 단계 1에서 변수를 캡슐화하며 만든 게터 \ No newline at end of file diff --git a/chapter07/src/case03/__init__.py b/chapter07/src/case03/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case03/case03.py b/chapter07/src/case03/case03.py new file mode 100644 index 0000000..b306334 --- /dev/null +++ b/chapter07/src/case03/case03.py @@ -0,0 +1,11 @@ +# order.py +class Order: + def __init__(self, data): + self.priority = data.get("priority", "normal") # 기본값 설정 + self.product = data.get("product") + self.quantity = data.get("quantity", 0) + self.buyer = data.get("buyer") + + +def count_high_priority_orders(orders): + return len([order for order in orders if order.priority in ("high", "rush")]) diff --git a/chapter07/tests/case03/__init__.py b/chapter07/tests/case03/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case03/test_case03.py b/chapter07/tests/case03/test_case03.py new file mode 100644 index 0000000..b1ccd63 --- /dev/null +++ b/chapter07/tests/case03/test_case03.py @@ -0,0 +1,49 @@ +# test_order.py +from src.case03.case03 import Order, count_high_priority_orders + + +def test_order_creation(): + # 기본 주문 생성 테스트 + data = {"priority": "high", "product": "Widget", "quantity": 5, "buyer": "John Doe"} + order = Order(data) + assert order.priority == "high" + assert order.product == "Widget" + assert order.quantity == 5 + assert order.buyer == "John Doe" + + +def test_order_with_missing_data(): + # 누락된 데이터가 있는 경우 테스트 + data = {"product": "Widget"} + order = Order(data) + assert order.priority == "normal" # 기본값 확인 + assert order.quantity == 0 # 기본값 확인 + assert order.product == "Widget" + assert order.buyer is None + + +def test_high_priority_orders_count(): + # 고우선순위 주문 카운팅 테스트 + orders = [ + Order({"priority": "high", "product": "A"}), + Order({"priority": "rush", "product": "B"}), + Order({"priority": "normal", "product": "C"}), + Order({"priority": "low", "product": "D"}), + Order({"priority": "high", "product": "E"}), + ] + + assert count_high_priority_orders(orders) == 3 + + +def test_empty_orders_list(): + # 빈 주문 리스트 테스트 + assert count_high_priority_orders([]) == 0 + + +def test_no_high_priority_orders(): + # 고우선순위 주문이 없는 경우 테스트 + orders = [ + Order({"priority": "normal", "product": "A"}), + Order({"priority": "low", "product": "B"}), + ] + assert count_high_priority_orders(orders) == 0 From fbc30ff4922cae4f675e54ba9e90fcd90b531f3e Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 00:51:58 +0900 Subject: [PATCH 18/48] refactor(chapter7): encapsulate priority handling in Priority class and update Order methods --- chapter07/src/case03/README.md | 2 +- chapter07/src/case03/case03.py | 26 +++++++++++++++++++----- chapter07/tests/case03/test_case03.py | 29 +++++++++++++++++++-------- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/chapter07/src/case03/README.md b/chapter07/src/case03/README.md index 38a52d5..806e715 100644 --- a/chapter07/src/case03/README.md +++ b/chapter07/src/case03/README.md @@ -57,4 +57,4 @@ orders = list(filter(lambda o: o.priority.higher_than(Priority("normal")), order [^1]: 단계 1에서 변수를 캡슐화하며 만든 세터 -[^1]: 단계 1에서 변수를 캡슐화하며 만든 게터 \ No newline at end of file +[^2]: 단계 1에서 변수를 캡슐화하며 만든 게터 diff --git a/chapter07/src/case03/case03.py b/chapter07/src/case03/case03.py index b306334..f3ddbcf 100644 --- a/chapter07/src/case03/case03.py +++ b/chapter07/src/case03/case03.py @@ -1,11 +1,27 @@ # order.py +class Priority: + def __init__(self, priority): + self._priority = priority + + def __str__(self): + return self._priority + + class Order: def __init__(self, data): - self.priority = data.get("priority", "normal") # 기본값 설정 - self.product = data.get("product") - self.quantity = data.get("quantity", 0) - self.buyer = data.get("buyer") + self._priority = data.get("priority", "normal") # 기본값 설정 + self._product = data.get("product") + self._quantity = data.get("quantity", 0) + self._buyer = data.get("buyer") + + def priority_string(self): + return str(self._priority) + + def set_priority(self, value): + self._priority = Priority(value) def count_high_priority_orders(orders): - return len([order for order in orders if order.priority in ("high", "rush")]) + return len( + [order for order in orders if order.priority_string() in ("high", "rush")] + ) diff --git a/chapter07/tests/case03/test_case03.py b/chapter07/tests/case03/test_case03.py index b1ccd63..8460d3e 100644 --- a/chapter07/tests/case03/test_case03.py +++ b/chapter07/tests/case03/test_case03.py @@ -1,4 +1,5 @@ # test_order.py +import pytest from src.case03.case03 import Order, count_high_priority_orders @@ -6,20 +7,20 @@ def test_order_creation(): # 기본 주문 생성 테스트 data = {"priority": "high", "product": "Widget", "quantity": 5, "buyer": "John Doe"} order = Order(data) - assert order.priority == "high" - assert order.product == "Widget" - assert order.quantity == 5 - assert order.buyer == "John Doe" + assert order.priority_string() == "high" + assert order._product == "Widget" + assert order._quantity == 5 + assert order._buyer == "John Doe" def test_order_with_missing_data(): # 누락된 데이터가 있는 경우 테스트 data = {"product": "Widget"} order = Order(data) - assert order.priority == "normal" # 기본값 확인 - assert order.quantity == 0 # 기본값 확인 - assert order.product == "Widget" - assert order.buyer is None + assert order.priority_string() == "normal" # 기본값 확인 + assert order._quantity == 0 # 기본값 확인 + assert order._product == "Widget" + assert order._buyer is None def test_high_priority_orders_count(): @@ -47,3 +48,15 @@ def test_no_high_priority_orders(): Order({"priority": "low", "product": "B"}), ] assert count_high_priority_orders(orders) == 0 + + +# 나중의 리팩토링을 위한 Priority 클래스 테스트 +@pytest.mark.skip(reason="Priority 클래스 리팩토링 준비") +def test_priority_comparison(): + # 향후 Priority 클래스로 리팩토링할 때 사용할 테스트 + data1 = {"priority": "high"} + data2 = {"priority": "normal"} + order1 = Order(data1) + order2 = Order(data2) + # Priority 클래스 리팩토링 후 아래 테스트 활성화 + # assert order1.priority.higher_than(order2.priority) From ba71786393269221fbb9434bba407be24a4d3b9e Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 00:57:55 +0900 Subject: [PATCH 19/48] feat(chapter7): refactor Priority class for improved priority handling in Order --- chapter07/src/case03/case03.py | 30 +++++++++++++++++++-------- chapter07/tests/case03/test_case03.py | 14 +++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/chapter07/src/case03/case03.py b/chapter07/src/case03/case03.py index f3ddbcf..bf0fb69 100644 --- a/chapter07/src/case03/case03.py +++ b/chapter07/src/case03/case03.py @@ -1,27 +1,39 @@ # order.py class Priority: - def __init__(self, priority): - self._priority = priority + _values = ("low", "normal", "high", "rush") + + def __init__(self, value): + if value not in self._values: + raise ValueError(f"Invalid priority value: {value}") + self._value = value + + def higher_than(self, other): + return self._values.index(self._value) > self._values.index(other._value) def __str__(self): - return self._priority + return self._value + + def __eq__(self, other): + return self._values.index(self._value) == self._values.index(other._value) class Order: def __init__(self, data): - self._priority = data.get("priority", "normal") # 기본값 설정 + self._priority = Priority(data.get("priority", "normal")) self._product = data.get("product") self._quantity = data.get("quantity", 0) self._buyer = data.get("buyer") - def priority_string(self): - return str(self._priority) + @property + def priority(self): + return self._priority - def set_priority(self, value): - self._priority = Priority(value) + @priority.setter + def priority(self, value): + self._priority = Priority(value) if isinstance(value, str) else value def count_high_priority_orders(orders): return len( - [order for order in orders if order.priority_string() in ("high", "rush")] + [order for order in orders if order.priority.higher_than(Priority("normal"))] ) diff --git a/chapter07/tests/case03/test_case03.py b/chapter07/tests/case03/test_case03.py index 8460d3e..1e3dba9 100644 --- a/chapter07/tests/case03/test_case03.py +++ b/chapter07/tests/case03/test_case03.py @@ -1,13 +1,16 @@ # test_order.py -import pytest -from src.case03.case03 import Order, count_high_priority_orders +from src.case03.case03 import ( + count_high_priority_orders, + Priority, + Order, +) def test_order_creation(): # 기본 주문 생성 테스트 data = {"priority": "high", "product": "Widget", "quantity": 5, "buyer": "John Doe"} order = Order(data) - assert order.priority_string() == "high" + assert order.priority == Priority("high") assert order._product == "Widget" assert order._quantity == 5 assert order._buyer == "John Doe" @@ -17,7 +20,7 @@ def test_order_with_missing_data(): # 누락된 데이터가 있는 경우 테스트 data = {"product": "Widget"} order = Order(data) - assert order.priority_string() == "normal" # 기본값 확인 + assert order.priority == Priority("normal") # 기본값 확인 assert order._quantity == 0 # 기본값 확인 assert order._product == "Widget" assert order._buyer is None @@ -51,7 +54,6 @@ def test_no_high_priority_orders(): # 나중의 리팩토링을 위한 Priority 클래스 테스트 -@pytest.mark.skip(reason="Priority 클래스 리팩토링 준비") def test_priority_comparison(): # 향후 Priority 클래스로 리팩토링할 때 사용할 테스트 data1 = {"priority": "high"} @@ -59,4 +61,4 @@ def test_priority_comparison(): order1 = Order(data1) order2 = Order(data2) # Priority 클래스 리팩토링 후 아래 테스트 활성화 - # assert order1.priority.higher_than(order2.priority) + assert order1.priority.higher_than(order2.priority) From 079446e961737a6aaed6ed3417fa9f1afa52fd57 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 01:23:47 +0900 Subject: [PATCH 20/48] feat(chapter7): implement Order and Item classes with price calculation logic --- chapter07/src/case04/README.md | 60 ++++++++++++++++++++++ chapter07/src/case04/__init__.py | 0 chapter07/src/case04/case04.py | 17 +++++++ chapter07/tests/case04/__init__.py | 0 chapter07/tests/case04/test_case04.py | 73 +++++++++++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 chapter07/src/case04/README.md create mode 100644 chapter07/src/case04/__init__.py create mode 100644 chapter07/src/case04/case04.py create mode 100644 chapter07/tests/case04/__init__.py create mode 100644 chapter07/tests/case04/test_case04.py diff --git a/chapter07/src/case04/README.md b/chapter07/src/case04/README.md new file mode 100644 index 0000000..71a77d0 --- /dev/null +++ b/chapter07/src/case04/README.md @@ -0,0 +1,60 @@ +# 7.4 임시 변수를 질의 함수로 바꾸기 + +_Replace Temp with Query_ + +## 개요 + +Before + +```python +def calculate_price(self): + base_price = self._quantity * self._item_price + if base_price > 1000: + return base_price * 0.95 + else: + return base_price * 0.98 +``` + +After + +```python +@property +def base_price(self): + return self._quantity * self._item_price + + +def calculate_price(self): + if self.base_price > 1000: + return self.base_price * 0.95 + else: + return self.base_price * 0.98 +``` + +## 배경 + +함수 내에서 쓰는 결과값을 뒤에서 다시 참조하기 위해 임시변수를 쓴다. 이 아이디어는 굿. 더 좋은 아이디어는 아예 함수로 만들어서 쓰는 것이다. + +긴 함수의 한 부분을 별도 함수로 뺄 때 먼저 변수들을 각각의 함수로 만들면 일이 수월해진다. 추출한 함수에 변수를 따로 전달할 필요가 없어지니까. +이러면 추출한 함수와 원래 함수의 경계도 더 명확해진다. 이상한 의존관계를 떼어내고 사이드 이펙트를 제거할 수 있다. +비슷한 계산을 수행하는 다른 함수에서도 쓸 수 있다. 코드 중복을 줄일 수 있다는 뜻이다. + +이 리팩터링은 클래스 안에서 적용하면 효과가 가장 크다. + +하지만 장점만 있는 것은 아니다. 변수는 값을 한 번만 계산하고 그 뒤로는 읽기만 하게 하는게 좋다. +예를 들어 변수에 값을 한 번 대입한 후 더 복잡한 코드 덩어리에서 여러 차례 다시 대입하면 모두 질의함수로 뽑아내야 한다. +이렇게 리팩터하면 변수가 다음번에 또 쓰일 때 항상 같은 값을 내야한다. + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 변수가 사용되기 전에 값이 확실히 결정되는지, 변수를 사용할 때마다 계산 로직이 매번 다른 결과를 만들어내는 건 아닌지 확인한다. +2. 읽기전용으로 만들 수 있는 변수는 읽기 전용으로 만든다. +3. 테스트한다 +4. 변수 대입문을 함수로 추출한다
+ → 변수와 함수가 같은 이름을 가질 수 없다면 함수 이름을 임시로 짓는다. 추출한 함수가 사이드이펙트를 내지 않는지 확인한다. 사이드이펙트가 있다면 질의 함수와 변경 함수 분리하기( + 11.1절)로 대처한다 +5. 테스트한다 +6. 변수 인라인하기(6.4절)로 임시 변수를 제거한다 + +## 예시 diff --git a/chapter07/src/case04/__init__.py b/chapter07/src/case04/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case04/case04.py b/chapter07/src/case04/case04.py new file mode 100644 index 0000000..2ec789b --- /dev/null +++ b/chapter07/src/case04/case04.py @@ -0,0 +1,17 @@ +class Order: + def __init__(self, quantity, item): + self._quantity = quantity + self._item = item + + @property + def price(self): + base_price = self._quantity * self._item.price + discount_factor = 0.98 + if base_price > 1000: + discount_factor -= 0.03 + return base_price * discount_factor + + +class Item: + def __init__(self, price): + self.price = price diff --git a/chapter07/tests/case04/__init__.py b/chapter07/tests/case04/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case04/test_case04.py b/chapter07/tests/case04/test_case04.py new file mode 100644 index 0000000..97132cb --- /dev/null +++ b/chapter07/tests/case04/test_case04.py @@ -0,0 +1,73 @@ +from src.case04.case04 import ( + Order, + Item, +) + + +def test_order_price_with_small_order(): + """소액 주문(1000원 이하)에 대한 가격 계산 테스트""" + # 테스트 준비: 1000원 이하의 주문 (예: 10개 * 90원 = 900원) + item = Item(90) + order = Order(10, item) + + # 예상 결과: 900 * 0.98 = 882 + expected_price = 900 * 0.98 + + # 검증 + assert order.price == expected_price + assert ( + abs(order.price - expected_price) < 0.0001 + ) # 부동소수점 비교를 위한 오차 허용 + + +def test_order_price_with_large_order(): + """대액 주문(1000원 초과)에 대한 가격 계산 테스트""" + # 테스트 준비: 1000원 초과의 주문 (예: 20개 * 60원 = 1200원) + item = Item(60) + order = Order(20, item) + + # 예상 결과: 1200 * 0.95 = 1140 + expected_price = 1200 * 0.95 + + # 검증 + assert order.price == expected_price + assert ( + abs(order.price - expected_price) < 0.0001 + ) # 부동소수점 비교를 위한 오차 허용 + + +def test_order_with_exact_threshold(): + """정확히 경계값(1000원)에 대한 가격 계산 테스트""" + # 테스트 준비: 정확히 1000원의 주문 + item = Item(100) + order = Order(10, item) + + # 예상 결과: 1000 * 0.98 = 980 (1000원은 초과가 아니므로 0.98 적용) + expected_price = 1000 * 0.98 + + # 검증 + assert order.price == expected_price + + +def test_order_with_zero_quantity(): + """수량이 0인 경우 테스트""" + item = Item(100) + order = Order(0, item) + + # 예상 결과: 0 * 0.98 = 0 + expected_price = 0 + + # 검증 + assert order.price == expected_price + + +def test_order_with_zero_price(): + """아이템 가격이 0인 경우 테스트""" + item = Item(0) + order = Order(10, item) + + # 예상 결과: 0 * 0.98 = 0 + expected_price = 0 + + # 검증 + assert order.price == expected_price From 1a0fe5570399109a1bd0ff74f31779c5fea7de7a Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 01:32:50 +0900 Subject: [PATCH 21/48] feat(chapter7): add base_price property to calculate item price before discounts --- chapter07/src/case04/case04.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chapter07/src/case04/case04.py b/chapter07/src/case04/case04.py index 2ec789b..49b575e 100644 --- a/chapter07/src/case04/case04.py +++ b/chapter07/src/case04/case04.py @@ -3,9 +3,13 @@ def __init__(self, quantity, item): self._quantity = quantity self._item = item + @property + def base_price(self): + return self._quantity * self._item.price + @property def price(self): - base_price = self._quantity * self._item.price + base_price = self.base_price discount_factor = 0.98 if base_price > 1000: discount_factor -= 0.03 From 54d9a7eb41fb3ddaa5d53f8ca914f35f1504300a Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 01:33:29 +0900 Subject: [PATCH 22/48] fix(chapter7): correct price calculation to use instance base_price directly --- chapter07/src/case04/case04.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/chapter07/src/case04/case04.py b/chapter07/src/case04/case04.py index 49b575e..eb6c520 100644 --- a/chapter07/src/case04/case04.py +++ b/chapter07/src/case04/case04.py @@ -9,11 +9,10 @@ def base_price(self): @property def price(self): - base_price = self.base_price discount_factor = 0.98 - if base_price > 1000: + if self.base_price > 1000: discount_factor -= 0.03 - return base_price * discount_factor + return self.base_price * discount_factor class Item: From 966d400fc47c5682c52d508aa4403fc57d48999e Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 01:36:36 +0900 Subject: [PATCH 23/48] refactor(chapter7): separate discount factor calculation from price property --- chapter07/src/case04/case04.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/chapter07/src/case04/case04.py b/chapter07/src/case04/case04.py index eb6c520..0b34594 100644 --- a/chapter07/src/case04/case04.py +++ b/chapter07/src/case04/case04.py @@ -8,11 +8,16 @@ def base_price(self): return self._quantity * self._item.price @property - def price(self): + def discount_factor(self): discount_factor = 0.98 if self.base_price > 1000: discount_factor -= 0.03 - return self.base_price * discount_factor + + return discount_factor + + @property + def price(self): + return self.base_price * self.discount_factor class Item: From 6593f3cd050ecfc5138fd80099d97f40ad7a2a86 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 23:15:50 +0900 Subject: [PATCH 24/48] docs(chapter7): add section on class extraction with examples and procedures --- chapter07/src/case01/README.md | 29 +++++++------ chapter07/src/case02/README.md | 10 ++--- chapter07/src/case03/README.md | 9 ++-- chapter07/src/case04/README.md | 2 - chapter07/src/case05/README.md | 79 ++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 27 deletions(-) create mode 100644 chapter07/src/case05/README.md diff --git a/chapter07/src/case01/README.md b/chapter07/src/case01/README.md index 8c78897..7bacbdf 100644 --- a/chapter07/src/case01/README.md +++ b/chapter07/src/case01/README.md @@ -18,9 +18,9 @@ After ```python class Organization: def __init__( - self, - name: str, - country: str, + self, + name: str, + country: str, ): self._name = name self._country = country @@ -47,22 +47,22 @@ class Organization: ```python class Organization: def __init__( - self, - name: str, - country: str, + self, + name: str, + country: str, ): self.name = name self.country = country ->>> org = Organization(name="test", country="KR") ->>> org.name +>> > org = Organization(name="test", country="KR") +>> > org.name test ``` ## 배경 -데이터를 넣을 때 이런 유의미한 값을 넣을 수 있다. 매우 직관적으로 넣을 수 있지만, 단순하면 세세함을 챙기기 어렵다. -특히나 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 가르기가 너무 어렵다. +데이터를 넣을 때 이런 유의미한 값을 넣을 수 있다. 매우 직관적으로 넣을 수 있지만, 단순하면 세세함을 챙기기 어렵다. +특히나 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 가르기가 너무 어렵다. 마틴 파울러는 이 때문에 가변 데이터(_mutable_)를 레코드보다 객체를 더 선호한다. @@ -72,6 +72,7 @@ test 가변 데이터일 때는 객체면 좋고, 불변(_immutable_) 데이터면 값을 모두 구해서 저장하고, 이름을 바꿀 때는 필드를 복제한다. 이런 레코드 구조는 두 가지로 갈린다: + 1. 필드 이름을 노출하는 경우 2. (필드를 외부로부터 숨겨서) 내가 원하는 이름을 쓰는 경우 - hash, map, hashmap, dictionary(!), associative array 등의 이름 @@ -80,14 +81,13 @@ test ## 절차 -`
`, `→` 복사해서 쓰기 - 1. 레코드를 담은 변수를 캡슐화(6.6절)한다 2. 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다. 이 클래스에 원본 레코드를 반환하는 접근자도 정의하고, 변수를 캡슐화하는 함수들이 이 접근자를 사용하도록 수정한다. 3. 테스트한다 4. 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다 -5. 레코드를 반환하는 예전 함수를 사용하는 코드를 4에서 만든 새 함수로 갈아끼운다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없으면 추가한다. 한 부분을 바꿀 때마다 테스트한다.
-→ 중첩된 구조처럼 복잡한 레코드면 데이터 갱신하는 클라이언트에 주의해서 살펴본다. 클라이언트가 데이터를 읽기만 한다면 데이터의 복제본이나 읽기 전용 프록시를 반환할 건지 고려 필요 +5. 레코드를 반환하는 예전 함수를 사용하는 코드를 4에서 만든 새 함수로 갈아끼운다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없으면 추가한다. 한 부분을 바꿀 때마다 + 테스트한다.
+ → 중첩된 구조처럼 복잡한 레코드면 데이터 갱신하는 클라이언트에 주의해서 살펴본다. 클라이언트가 데이터를 읽기만 한다면 데이터의 복제본이나 읽기 전용 프록시를 반환할 건지 고려 필요 6. 클래스에서 원본 데이터를 반환하는 접근자와 (1에서 검색하기 쉬운 이름을 붙여둔) 원본 레코드를 반환하는 함수를 제거한다 7. 테스트한다 8. 레코드의 필드에도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슈로하하기(7.2절)를 재귀적으로 적용한다. @@ -97,6 +97,7 @@ test 상수 캡슐화는 여기서한다 (fb38e282e65613245fb88f6c172887d570fc4003) 이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다 (d9f0aed76d8ef866453f1d06a349d326423300c7) + - 레코드를 클래스로 바꾸기 - 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다 diff --git a/chapter07/src/case02/README.md b/chapter07/src/case02/README.md index 3981fc7..d6bf689 100644 --- a/chapter07/src/case02/README.md +++ b/chapter07/src/case02/README.md @@ -14,7 +14,7 @@ class Person: @property def courses(self): return self._courses - + @courses.setter def courses(self, a_list): self._courses = a_list @@ -24,7 +24,8 @@ class Person: After ```python -import copy # for shallow copy +import copy # for shallow copy + class Person: def __init__(self, courses): @@ -35,6 +36,7 @@ class Person: return copy.copy(self._courses) def add_course(self, a_course): ... + def remove_course(self, a_course): ... ``` @@ -53,11 +55,9 @@ class Person: ## 절차 -`
`, `→` 복사해서 쓰기 - 1. 컬렉션을 캡슐화부터 한다(6.6절) 2. 컬렉션에 원소를 추가/제거 하는 함수를 추가한다
-→ 컬렉션 자체를 통째로 바꾸는 세터는 제거한다(11.7절). 세터를 제거할 수 없다면 인수로 받은 컬렉션을 복제해 저장하게 만든다 + → 컬렉션 자체를 통째로 바꾸는 세터는 제거한다(11.7절). 세터를 제거할 수 없다면 인수로 받은 컬렉션을 복제해 저장하게 만든다 3. 정적 검사를 수행한다 4. 컬렉션을 참조하는 부분을 모두 찾는다. 컬렉션의 변경자를 호출하는 코드가 모두 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다. 하나씩 바꿀 때마다 테스트한다. 5. 컬렉션 게터를 수정해서 원본 내용을 수정할 수 없는 읽기전용 프록시나 복제본을 반환하게 한다. diff --git a/chapter07/src/case03/README.md b/chapter07/src/case03/README.md index 806e715..6ffa091 100644 --- a/chapter07/src/case03/README.md +++ b/chapter07/src/case03/README.md @@ -17,12 +17,12 @@ After # ...객체화 class Priority: _values = ("low", "normal", "high", "rush") - + def __init__(self, value): if value not in self._values: raise ValueError(f"Invalid priority value: {value}") self._value = value - + def higher_than(self, other): return self._values.index(self._value) > self._values.index(other._value) @@ -42,8 +42,6 @@ orders = list(filter(lambda o: o.priority.higher_than(Priority("normal")), order ## 절차 -`
`, `→` 복사해서 쓰기 - 1. 아직 변수를 캡슐화 하지 않았다면 캡슐화(6.6절) 한다. 2. 단순한 값 클래스(_value class_)를 만든다. 생성자는 기존 값을 인수로 받아 저장하고, 이 값을 반환하게 한다 3. 정적 검사를 수행한다 @@ -51,10 +49,9 @@ orders = list(filter(lambda o: o.priority.higher_than(Priority("normal")), order 5. 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터[^2]를 수정한다 6. 테스트한다 7. 함수 이름을 바꾸면(6.5절) 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다
-→ 참조를 값으로 바꾸거나(9.4절), 값을 참조로 바꾸면(9.5절) 새로 만든 객체의 역할(값, 참조 객체)이 더 잘 드러나는지 검토한다. + → 참조를 값으로 바꾸거나(9.4절), 값을 참조로 바꾸면(9.5절) 새로 만든 객체의 역할(값, 참조 객체)이 더 잘 드러나는지 검토한다. ## 예시 - [^1]: 단계 1에서 변수를 캡슐화하며 만든 세터 [^2]: 단계 1에서 변수를 캡슐화하며 만든 게터 diff --git a/chapter07/src/case04/README.md b/chapter07/src/case04/README.md index 71a77d0..3c88d7b 100644 --- a/chapter07/src/case04/README.md +++ b/chapter07/src/case04/README.md @@ -46,8 +46,6 @@ def calculate_price(self): ## 절차 -`
`, `→` 복사해서 쓰기 - 1. 변수가 사용되기 전에 값이 확실히 결정되는지, 변수를 사용할 때마다 계산 로직이 매번 다른 결과를 만들어내는 건 아닌지 확인한다. 2. 읽기전용으로 만들 수 있는 변수는 읽기 전용으로 만든다. 3. 테스트한다 diff --git a/chapter07/src/case05/README.md b/chapter07/src/case05/README.md new file mode 100644 index 0000000..4eb6dd0 --- /dev/null +++ b/chapter07/src/case05/README.md @@ -0,0 +1,79 @@ +# 7.5 클래스 추출하기 + +_Extract Class_ + +## 개요 + +Before + +```python +class Person: + def __init__(self, office_area_code, office_number): + self._office_area_code = office_area_code + self._office_number = office_number + + @property + def office_area_code(self): + return self._office_area_code + + @property + def office_number(self): + return self._office_number +``` + +After + +```python +class Person: + def __init__(self, telephone_number): + self._telephone_number = telephone_number + + @property + def office_area_code(self): + return self._telephone_number.area_code + + @property + def office_number(self): + return self._telephone_number.number + + +class TelephoneNumber: + def __init__(self, area_code, number): + self._area_code = area_code + self._number = number + + @property + def area_code(self): + return self._area_code + + @property + def number(self): + return self._number +``` + +## 배경 + +"클래스는 명확하게 추상화하고 소수의 주어진 역할만을 처리하도록 하자." 가 원래 가이드라인이지만 인생사 그렇게 흘러가지 않는다. +연산을 넣고 데이터도 보강하다보면 점점 커지는데, 그러면 클래스가 비대해진다. + +메소드와 데이터가 너무 많으면 이해하기 쉽지 않으니 분리하는 것이 좋다. +일부 데이터와 메소드를 따로 묶을 수 있겠다 싶으면 분리하기 좋은 신호다. +함께 변경되는 일이 많거나 서로 의존하는 데이터도 분리한다. +이럴 땐 데이터나 메소드 일부를 빼보고 자문해보자. 빼고서도 다른 필드나 메소드들에 문제가 없다면 ㅇㅋ + +서브클래스가 만들어지는 방식에서 징후가 나타날 수 있다. 작은 기능 일부를 위한 서브클래스, 확장할 기능이 무엇이냐에 따라 서브클래스가 다르게 만들어진다면 클래스를 나누라는 의미. + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 클래스의 역할을 분리할 방법을 정한다 +2. 분리될 역할을 담당할 클래스를 새로 만든다
+ → 원래 클래스에 남은 역할과 클래스의 이름이 어울리지 않는다면 적절히 바꾼다 +3. 원래 클래스의 생성자에서 새로운 클래스의 인스턴스를 생성하여 필드에 저장한다 +4. 분리될 역할에 필요한 필드들을 새 클래스로 옮긴다(필드 옮기기, 8.2절). 하나씩 옮기고 테스트한다 +5. 메소드들도 새 클래스로 옮긴다(함수 옮기기, 8.1절). 저수준 메소드(호출당하는 일이 많은 메소드)부터 옮긴다. 하나씩 옮기고 테스트한다 +6. 양쪽 클래스의 인터페이스를 살펴보면서 불필요한 메소드를 제거하고, 이름도 새 환경에 맞게 바꾼다. +7. 새 클래스를 외부로 노출할지 정한다. 노출하려는 경우 새 클래스에 참조를 값으로 바꾸기(9.4절)를 적용할지 고민한다. + +## 예시 From a4852cf1851127e3d643f60be8dcf4464e15b532 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 23:22:32 +0900 Subject: [PATCH 25/48] feat(chapter7): add Person class with office area code and number properties --- chapter07/src/case05/__init__.py | 0 chapter07/src/case05/case05.py | 12 +++++++++++ chapter07/tests/case05/__init__.py | 0 chapter07/tests/case05/test_case05.py | 29 +++++++++++++++++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 chapter07/src/case05/__init__.py create mode 100644 chapter07/src/case05/case05.py create mode 100644 chapter07/tests/case05/__init__.py create mode 100644 chapter07/tests/case05/test_case05.py diff --git a/chapter07/src/case05/__init__.py b/chapter07/src/case05/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case05/case05.py b/chapter07/src/case05/case05.py new file mode 100644 index 0000000..3c5e153 --- /dev/null +++ b/chapter07/src/case05/case05.py @@ -0,0 +1,12 @@ +class Person: + def __init__(self, office_area_code, office_number): + self._office_area_code = office_area_code + self._office_number = office_number + + @property + def office_area_code(self): + return self._office_area_code + + @property + def office_number(self): + return self._office_number diff --git a/chapter07/tests/case05/__init__.py b/chapter07/tests/case05/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case05/test_case05.py b/chapter07/tests/case05/test_case05.py new file mode 100644 index 0000000..5e39ce9 --- /dev/null +++ b/chapter07/tests/case05/test_case05.py @@ -0,0 +1,29 @@ +# tests/test_person.py + +import pytest +from src.case05.case05 import Person + + +def test_person_initialization(): + """Person 클래스가 정상적으로 초기화되는지 테스트합니다.""" + office_area_code = "010" + office_number = "1234-5678" + person = Person(office_area_code, office_number) + + assert person.office_area_code == office_area_code + assert person.office_number == office_number + + +@pytest.mark.parametrize( + "area_code, number", + [ + ("02", "9876-5432"), + ("031", "111-2222"), + ("010", "0000-0000"), + ], +) +def test_person_multiple_cases(area_code, number): + """다양한 케이스에 대한 Person 초기화 및 프로퍼티 검증.""" + person = Person(area_code, number) + assert person.office_area_code == area_code + assert person.office_number == number From 07ebffb8c26d91622dc93de7ecb16c7b8fe1e5e9 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 23:37:45 +0900 Subject: [PATCH 26/48] feat(chapter7): refactor Person class to use TelephoneNumber for office contact --- chapter07/src/case05/README.md | 2 ++ chapter07/src/case05/case05.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/chapter07/src/case05/README.md b/chapter07/src/case05/README.md index 4eb6dd0..2cad13e 100644 --- a/chapter07/src/case05/README.md +++ b/chapter07/src/case05/README.md @@ -77,3 +77,5 @@ class TelephoneNumber: 7. 새 클래스를 외부로 노출할지 정한다. 노출하려는 경우 새 클래스에 참조를 값으로 바꾸기(9.4절)를 적용할지 고민한다. ## 예시 + +전화번호만 별도로 빼보자. \ No newline at end of file diff --git a/chapter07/src/case05/case05.py b/chapter07/src/case05/case05.py index 3c5e153..e67cbbd 100644 --- a/chapter07/src/case05/case05.py +++ b/chapter07/src/case05/case05.py @@ -1,7 +1,6 @@ class Person: - def __init__(self, office_area_code, office_number): - self._office_area_code = office_area_code - self._office_number = office_number + def __init__(self, telephone_number): + self._telephone_number = telephone_number @property def office_area_code(self): @@ -10,3 +9,12 @@ def office_area_code(self): @property def office_number(self): return self._office_number + + +class TelephoneNumber: + def __init__(self, area_code): + self._area_code = area_code + + @property + def area_code(self): + return self._area_code From f0797e280a91562fe474e00aa2aa303e36c8bacb Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 23:47:17 +0900 Subject: [PATCH 27/48] refactor(chapter7): refactor Person class to use TelephoneNumber for office contact details --- chapter07/src/case05/case05.py | 18 +++++++++++++++--- chapter07/tests/case05/test_case05.py | 17 ++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/chapter07/src/case05/case05.py b/chapter07/src/case05/case05.py index e67cbbd..a0a2558 100644 --- a/chapter07/src/case05/case05.py +++ b/chapter07/src/case05/case05.py @@ -4,17 +4,29 @@ def __init__(self, telephone_number): @property def office_area_code(self): - return self._office_area_code + return self._telephone_number.area_code @property def office_number(self): - return self._office_number + return self._telephone_number.number + + @property + def telephone_number(self): + return str(self._telephone_number) class TelephoneNumber: - def __init__(self, area_code): + def __init__(self, area_code, office_number): self._area_code = area_code + self._number = office_number + + def __str__(self): + return f"({self.area_code}) {self.number}" @property def area_code(self): return self._area_code + + @property + def number(self): + return self._number diff --git a/chapter07/tests/case05/test_case05.py b/chapter07/tests/case05/test_case05.py index 5e39ce9..9d17e5d 100644 --- a/chapter07/tests/case05/test_case05.py +++ b/chapter07/tests/case05/test_case05.py @@ -1,14 +1,17 @@ # tests/test_person.py import pytest -from src.case05.case05 import Person +from src.case05.case05 import ( + Person, + TelephoneNumber, +) def test_person_initialization(): """Person 클래스가 정상적으로 초기화되는지 테스트합니다.""" office_area_code = "010" office_number = "1234-5678" - person = Person(office_area_code, office_number) + person = Person(TelephoneNumber(office_area_code, office_number)) assert person.office_area_code == office_area_code assert person.office_number == office_number @@ -22,8 +25,8 @@ def test_person_initialization(): ("010", "0000-0000"), ], ) -def test_person_multiple_cases(area_code, number): - """다양한 케이스에 대한 Person 초기화 및 프로퍼티 검증.""" - person = Person(area_code, number) - assert person.office_area_code == area_code - assert person.office_number == number +def test_telephone_multiple_cases(area_code, number): + """다양한 케이스에 대한 Telephone 초기화 및 프로퍼티 검증.""" + telephone = TelephoneNumber(area_code, number) + assert telephone.area_code == area_code + assert telephone.number == number From b458c25e9998e46dfe68dff004564848d3e587e5 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Wed, 5 Mar 2025 23:48:02 +0900 Subject: [PATCH 28/48] docs(chapter7): update examples and clarify changes in naming and test code --- chapter07/src/case05/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chapter07/src/case05/README.md b/chapter07/src/case05/README.md index 2cad13e..8d7092a 100644 --- a/chapter07/src/case05/README.md +++ b/chapter07/src/case05/README.md @@ -78,4 +78,6 @@ class TelephoneNumber: ## 예시 -전화번호만 별도로 빼보자. \ No newline at end of file +전화번호만 별도로 빼보자. (07ebffb8c26d91622dc93de7ecb16c7b8fe1e5e9) + +뺀 이름에서 포괄적인 요소를 바꾸고 테스트코드도 갈아엎는다. (f0797e280a91562fe474e00aa2aa303e36c8bacb) From fe92998b7fd7cd2f730dc86b5865a910ba9faef3 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 00:36:23 +0900 Subject: [PATCH 29/48] feat(chapter7): add TrackingInformation and Shipment classes with basic functionality and tests --- chapter07/src/case06/README.md | 68 ++++++++++++++++++++ chapter07/src/case06/__init__.py | 0 chapter07/src/case06/case06.py | 47 ++++++++++++++ chapter07/tests/case06/__init__.py | 0 chapter07/tests/case06/test_case06.py | 90 +++++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 chapter07/src/case06/README.md create mode 100644 chapter07/src/case06/__init__.py create mode 100644 chapter07/src/case06/case06.py create mode 100644 chapter07/tests/case06/__init__.py create mode 100644 chapter07/tests/case06/test_case06.py diff --git a/chapter07/src/case06/README.md b/chapter07/src/case06/README.md new file mode 100644 index 0000000..00ca846 --- /dev/null +++ b/chapter07/src/case06/README.md @@ -0,0 +1,68 @@ +# 7.6 클래스 인라인하기 + +_Inline Class_ + +## 개요 + +Before + +```python +class Person: + def __init__(self, office_area_code, office_number): + self._office_area_code = office_area_code + self._office_number = office_number + + @property + def office_area_code(self): + return self._office_area_code + + @property + def office_number(self): + return self._office_number +``` + +After + +```python +class Person: + def __init__(self, telephone_number): + self._telephone_number = telephone_number + + @property + def office_area_code(self): + return self._telephone_number.area_code + + @property + def office_number(self): + return self._telephone_number.number + + +class TelephoneNumber: + def __init__(self, area_code, number): + self._area_code = area_code + self._number = number + + @property + def area_code(self): + return self._area_code + + @property + def number(self): + return self._number +``` + +## 배경 + +제 역할을 못 해서 그대로 두면 안 되는 클래스를 도로 인라인한다. 특정 클래스에 남은 역할이 거의 없으면 돌려놓자. + +혹은 두 클래스의 기능을 지금과 다르게 배분할 때도 인라인한다. 한번 합친 다음엔 새 클래슬 추출(7.5절)하는게 쉬울 수도 있다(??) +파울러는 코드 재구성할 때 이런 접근방식을 취하는 것을 권한다. 한 컨텍스트를 다른데 합쳐버리거나, 하나로 합친 후 적절히 다시 분리하는 등. + +## 절차 + +1. 소스 클래스의 각 public 메소드에 대응하는 메소드들을 타깃 클래스에 생성한다. 이 메소드들은 단순히 작업을 소스 클래스로 위임해야 한다. +2. 소스 클래스의 메소드를 사용하는 코드를 모두 타깃 클래스의 위임 메소드를 쓰도록 바꾼다. 하나씩 바꿀 때마다 테스트한다. +3. 소스 클래스의 메소드와 필드를 모두 타깃 클래스로 옮긴다. 하나씩 옮길 때 마다 테스트한다. +4. 소스 클래스를 지운다. RIP + +## 예시 diff --git a/chapter07/src/case06/__init__.py b/chapter07/src/case06/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case06/case06.py b/chapter07/src/case06/case06.py new file mode 100644 index 0000000..9b184b1 --- /dev/null +++ b/chapter07/src/case06/case06.py @@ -0,0 +1,47 @@ +# tracking.py + + +class TrackingInformation: + def __init__(self): + self._shipping_company = None + self._tracking_number = None + + @property + def shipping_company(self): + return self._shipping_company + + @shipping_company.setter + def shipping_company(self, arg): + self._shipping_company = arg + + @property + def tracking_number(self): + return self._tracking_number + + @tracking_number.setter + def tracking_number(self, arg): + self._tracking_number = arg + + @property + def display(self): + return f"{self.shipping_company}: {self.tracking_number}" + + +class Shipment: + def __init__(self): + self._tracking_information = None + + @property + def tracking_info(self): + # 자바스크립트의 get trackingInfo() { return this._trackingInformation.display; }에 해당 + return ( + self._tracking_information.display if self._tracking_information else None + ) + + @property + def tracking_information(self): + return self._tracking_information + + @tracking_information.setter + def tracking_information(self, a_tracking_information): + self._tracking_information = a_tracking_information diff --git a/chapter07/tests/case06/__init__.py b/chapter07/tests/case06/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case06/test_case06.py b/chapter07/tests/case06/test_case06.py new file mode 100644 index 0000000..b1e1099 --- /dev/null +++ b/chapter07/tests/case06/test_case06.py @@ -0,0 +1,90 @@ +# tests/test_tracking.py +import pytest +from src.case06.case06 import ( + TrackingInformation, + Shipment, +) + + +def test_tracking_information_basic(): + """ + TrackingInformation을 생성하고, + shippingCompany와 trackingNumber를 세터로 설정한 뒤 + 제대로 게터로 반환되는지, display 문자열이 정상인지 검증 + """ + tracking = TrackingInformation() + + # 초기 상태 확인 (None) + assert tracking.shipping_company is None + assert tracking.tracking_number is None + + # 세터로 값 설정 + tracking.shipping_company = "FedEx" + tracking.tracking_number = "123456" + + # 게터 및 display 확인 + assert tracking.shipping_company == "FedEx" + assert tracking.tracking_number == "123456" + assert tracking.display == "FedEx: 123456" + + +@pytest.mark.parametrize( + "company, number, expected_display", + [ + ("UPS", "999999", "UPS: 999999"), + ("DHL", "ABC123", "DHL: ABC123"), + ("KoreaPost", "987654", "KoreaPost: 987654"), + ], +) +def test_tracking_information_param(company, number, expected_display): + """ + 여러 케이스를 파라미터라이즈하여 TrackingInformation 테스트 + """ + tracking = TrackingInformation() + tracking.shipping_company = company + tracking.tracking_number = number + + assert tracking.display == expected_display + + +def test_shipment_basic(): + """ + Shipment에 TrackingInformation을 연결해보고, + trackingInfo 프로퍼티가 올바른 문자열을 반환하는지 확인 + """ + shipment = Shipment() + + # 기본적으로 None인 경우 확인 + assert shipment.tracking_info is None + + # TrackingInformation 생성 및 설정 + tracking = TrackingInformation() + tracking.shipping_company = "FedEx" + tracking.tracking_number = "654321" + + # Shipment에 세팅 + shipment.tracking_information = tracking + assert shipment.tracking_info == "FedEx: 654321" + + +@pytest.mark.parametrize( + "company, number, expected_info", + [ + ("CJ", "123123", "CJ: 123123"), + ("FedEx", "000111", "FedEx: 000111"), + ], +) +def test_shipment_param(company, number, expected_info): + """ + Shipment과 TrackingInformation을 함께 사용했을 때 + 다양한 입력값에 대해 trackingInfo가 올바른지 테스트 + """ + shipment = Shipment() + tracking = TrackingInformation() + + tracking.shipping_company = company + tracking.tracking_number = number + + shipment.tracking_information = tracking + + assert shipment.tracking_info == expected_info From d3bebc515ae5be50b193d6dfce282425485e3300 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 00:50:43 +0900 Subject: [PATCH 30/48] refactor(chapter7): replace TrackingInformation with Shipment class and update tests --- chapter07/src/case06/case06.py | 42 +++++++++------------- chapter07/tests/case06/test_case06.py | 52 ++++++--------------------- 2 files changed, 28 insertions(+), 66 deletions(-) diff --git a/chapter07/src/case06/case06.py b/chapter07/src/case06/case06.py index 9b184b1..f3547e3 100644 --- a/chapter07/src/case06/case06.py +++ b/chapter07/src/case06/case06.py @@ -1,11 +1,24 @@ -# tracking.py - - -class TrackingInformation: +class Shipment: def __init__(self): + self._tracking_information = None self._shipping_company = None self._tracking_number = None + @property + def tracking_info(self): + # 자바스크립트의 get trackingInfo() { return this._trackingInformation.display; }에 해당 + return ( + self._tracking_information.display if self._tracking_information else None + ) + + @property + def tracking_information(self): + return self._tracking_information + + @tracking_information.setter + def tracking_information(self, a_tracking_information): + self._tracking_information = a_tracking_information + @property def shipping_company(self): return self._shipping_company @@ -22,26 +35,5 @@ def tracking_number(self): def tracking_number(self, arg): self._tracking_number = arg - @property def display(self): return f"{self.shipping_company}: {self.tracking_number}" - - -class Shipment: - def __init__(self): - self._tracking_information = None - - @property - def tracking_info(self): - # 자바스크립트의 get trackingInfo() { return this._trackingInformation.display; }에 해당 - return ( - self._tracking_information.display if self._tracking_information else None - ) - - @property - def tracking_information(self): - return self._tracking_information - - @tracking_information.setter - def tracking_information(self, a_tracking_information): - self._tracking_information = a_tracking_information diff --git a/chapter07/tests/case06/test_case06.py b/chapter07/tests/case06/test_case06.py index b1e1099..16a0a3d 100644 --- a/chapter07/tests/case06/test_case06.py +++ b/chapter07/tests/case06/test_case06.py @@ -1,33 +1,10 @@ # tests/test_tracking.py import pytest from src.case06.case06 import ( - TrackingInformation, Shipment, ) -def test_tracking_information_basic(): - """ - TrackingInformation을 생성하고, - shippingCompany와 trackingNumber를 세터로 설정한 뒤 - 제대로 게터로 반환되는지, display 문자열이 정상인지 검증 - """ - tracking = TrackingInformation() - - # 초기 상태 확인 (None) - assert tracking.shipping_company is None - assert tracking.tracking_number is None - - # 세터로 값 설정 - tracking.shipping_company = "FedEx" - tracking.tracking_number = "123456" - - # 게터 및 display 확인 - assert tracking.shipping_company == "FedEx" - assert tracking.tracking_number == "123456" - assert tracking.display == "FedEx: 123456" - - @pytest.mark.parametrize( "company, number, expected_display", [ @@ -40,31 +17,27 @@ def test_tracking_information_param(company, number, expected_display): """ 여러 케이스를 파라미터라이즈하여 TrackingInformation 테스트 """ - tracking = TrackingInformation() - tracking.shipping_company = company - tracking.tracking_number = number + shipment = Shipment() + shipment.shipping_company = company + shipment.tracking_number = number - assert tracking.display == expected_display + assert shipment.display() == expected_display def test_shipment_basic(): """ - Shipment에 TrackingInformation을 연결해보고, - trackingInfo 프로퍼티가 올바른 문자열을 반환하는지 확인 + Shipment의 요소가 제대로 포함되어있나 확인 """ shipment = Shipment() # 기본적으로 None인 경우 확인 assert shipment.tracking_info is None - # TrackingInformation 생성 및 설정 - tracking = TrackingInformation() - tracking.shipping_company = "FedEx" - tracking.tracking_number = "654321" + shipment.shipping_company = "FedEx" + shipment.tracking_number = "654321" # Shipment에 세팅 - shipment.tracking_information = tracking - assert shipment.tracking_info == "FedEx: 654321" + assert shipment.display() == "FedEx: 654321" @pytest.mark.parametrize( @@ -80,11 +53,8 @@ def test_shipment_param(company, number, expected_info): 다양한 입력값에 대해 trackingInfo가 올바른지 테스트 """ shipment = Shipment() - tracking = TrackingInformation() - - tracking.shipping_company = company - tracking.tracking_number = number - shipment.tracking_information = tracking + shipment.shipping_company = company + shipment.tracking_number = number - assert shipment.tracking_info == expected_info + assert shipment.display() == expected_info From 6fe5561d165d57cfffe64b9c44c7d9a416349c5a Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:06:17 +0900 Subject: [PATCH 31/48] feat(chapter7): add Person and Department classes with basic functionality and tests --- chapter07/src/case07/README.md | 43 +++++++++++++++++ chapter07/src/case07/__init__.py | 0 chapter07/src/case07/case07.py | 41 ++++++++++++++++ chapter07/tests/case07/__init__.py | 0 chapter07/tests/case07/test_case07.py | 67 +++++++++++++++++++++++++++ 5 files changed, 151 insertions(+) create mode 100644 chapter07/src/case07/README.md create mode 100644 chapter07/src/case07/__init__.py create mode 100644 chapter07/src/case07/case07.py create mode 100644 chapter07/tests/case07/__init__.py create mode 100644 chapter07/tests/case07/test_case07.py diff --git a/chapter07/src/case07/README.md b/chapter07/src/case07/README.md new file mode 100644 index 0000000..0a433e3 --- /dev/null +++ b/chapter07/src/case07/README.md @@ -0,0 +1,43 @@ +# 7.7 위임 숨기기 + +_Hide Delegate_ + +## 개요 + +Before + +```python +manager = a_person.department.manager +``` + +After + +```python +manager = a_person.manager + + +class Person: + @property + def manager(self): + return self.department.manager +``` + +## 배경 + +모듈화 설계의 코어는 캡슐화다. 이는 모듈이 시스템의 다른 부분을 알 필요 없게 만든다. +이런 코드는 함께 고려해야할 것이 줄어들어서 코드 변경이 더 쉬워진다. + +그렇기에 캡슐화는 단순히 필드만을 숨기는 것이 아니라고 하는 것이다. + +예를들어 서버 객체의 필드가 가리키는 객체(_delegate_)의 메소드를 호출하려면 클라이언트는 위임객체를 알아야한다. +그런데 위임 객체의 인터페이스가 바뀌면 쓰는쪽도 다 바뀌어야 한다. 이런 의존성을 없애기 위해 서버 자체에 위임 메소드를 만들어서 위임 객체의 존재를 숨기면 된다. +이러면 위임 객체가 바뀌더라도 서버 코드만 고치면 된다. 클라이언트는 그대로 둬도 된다. + +## 절차 + +1. 위임 객체의 각 메소드에 해당하는 위임 메소드를 서버에 생성한다 +2. 클라이언트가 위임 객체 대신 서버를 호출하게 수정한다. 하나씩 바꿀 때마다 테스트한다 +3. 모두 수정했다면 서버로부터 위임객체를 얻는 접근자를 제거한다 +4. 테스트한다 + +## 예시 diff --git a/chapter07/src/case07/__init__.py b/chapter07/src/case07/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case07/case07.py b/chapter07/src/case07/case07.py new file mode 100644 index 0000000..08ad2fb --- /dev/null +++ b/chapter07/src/case07/case07.py @@ -0,0 +1,41 @@ +# organization.py + + +class Person: + def __init__(self, name): + self._name = name + self._department = None # 기본값 + + @property + def name(self): + return self._name + + @property + def department(self): + return self._department + + @department.setter + def department(self, arg): + self._department = arg + + +class Department: + def __init__(self): + self._charge_code = None + self._manager = None + + @property + def charge_code(self): + return self._charge_code + + @charge_code.setter + def charge_code(self, arg): + self._charge_code = arg + + @property + def manager(self): + return self._manager + + @manager.setter + def manager(self, arg): + self._manager = arg diff --git a/chapter07/tests/case07/__init__.py b/chapter07/tests/case07/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case07/test_case07.py b/chapter07/tests/case07/test_case07.py new file mode 100644 index 0000000..1416370 --- /dev/null +++ b/chapter07/tests/case07/test_case07.py @@ -0,0 +1,67 @@ +# tests/test_organization.py +import pytest +from src.case07.case07 import ( + Person, + Department, +) + + +def test_person_basic(): + """ + Person 객체를 생성하고, 기본 속성(name, department)이 제대로 동작하는지 테스트. + """ + p = Person("Alice") + assert p.name == "Alice" + assert p.department is None # 초기값 확인 + + +def test_department_basic(): + """ + Department 객체를 생성하고, chargeCode와 manager 게터/세터가 잘 동작하는지 테스트. + """ + d = Department() + assert d.charge_code is None + assert d.manager is None + + d.charge_code = "CODE123" + d.manager = "Bob" + + assert d.charge_code == "CODE123" + assert d.manager == "Bob" + + +def test_person_and_department(): + """ + Person에 Department를 연결했을 때 정상적으로 참조가 이뤄지는지 확인. + """ + p = Person("Alice") + d = Department() + + d.charge_code = "D100" + d.manager = "Charlie" + + # Person에 Department 객체를 세팅 + p.department = d + + assert p.department is d + assert p.department.charge_code == "D100" + assert p.department.manager == "Charlie" + + +@pytest.mark.parametrize( + "charge_code, manager", + [ + ("ENG-001", "Eve"), + ("SALES-007", "Mallory"), + ], +) +def test_department_parametrized(charge_code, manager): + """ + 다양한 케이스에 대해 Department의 chargeCode와 manager를 설정해보는 파라미터라이즈 테스트. + """ + d = Department() + d.charge_code = charge_code + d.manager = manager + + assert d.charge_code == charge_code + assert d.manager == manager From ec43be09d14b8264298c88360162c7fa0f8f1df6 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:12:53 +0900 Subject: [PATCH 32/48] refactor(chapter7): update Person class to require department and add manager property --- chapter07/src/case07/README.md | 2 ++ chapter07/src/case07/case07.py | 8 +++-- chapter07/tests/case07/test_case07.py | 50 ++++++++++++--------------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/chapter07/src/case07/README.md b/chapter07/src/case07/README.md index 0a433e3..d2554d8 100644 --- a/chapter07/src/case07/README.md +++ b/chapter07/src/case07/README.md @@ -41,3 +41,5 @@ class Person: 4. 테스트한다 ## 예시 + +클라이언트에서 어떤 사람이 속한 부서의 관리자를 알고싶다고 하자. \ No newline at end of file diff --git a/chapter07/src/case07/case07.py b/chapter07/src/case07/case07.py index 08ad2fb..cb33101 100644 --- a/chapter07/src/case07/case07.py +++ b/chapter07/src/case07/case07.py @@ -2,9 +2,9 @@ class Person: - def __init__(self, name): + def __init__(self, name, department): self._name = name - self._department = None # 기본값 + self._department = department @property def name(self): @@ -18,6 +18,10 @@ def department(self): def department(self, arg): self._department = arg + @property + def manager(self): + return self._department.manager + class Department: def __init__(self): diff --git a/chapter07/tests/case07/test_case07.py b/chapter07/tests/case07/test_case07.py index 1416370..a083fc5 100644 --- a/chapter07/tests/case07/test_case07.py +++ b/chapter07/tests/case07/test_case07.py @@ -7,45 +7,39 @@ def test_person_basic(): + """department를 반드시 넣어야함 + + :return: """ - Person 객체를 생성하고, 기본 속성(name, department)이 제대로 동작하는지 테스트. - """ - p = Person("Alice") - assert p.name == "Alice" - assert p.department is None # 초기값 확인 + with pytest.raises(TypeError): + Person("Alice") def test_department_basic(): """ Department 객체를 생성하고, chargeCode와 manager 게터/세터가 잘 동작하는지 테스트. """ - d = Department() - assert d.charge_code is None - assert d.manager is None + department = Department() + assert department.charge_code is None + assert department.manager is None - d.charge_code = "CODE123" - d.manager = "Bob" + department.charge_code = "CODE123" + department.manager = "Bob" - assert d.charge_code == "CODE123" - assert d.manager == "Bob" + assert department.charge_code == "CODE123" + assert department.manager == "Bob" def test_person_and_department(): """ Person에 Department를 연결했을 때 정상적으로 참조가 이뤄지는지 확인. """ - p = Person("Alice") - d = Department() - - d.charge_code = "D100" - d.manager = "Charlie" - - # Person에 Department 객체를 세팅 - p.department = d + department = Department() + department.charge_code = "D100" + department.manager = "Charlie" + sut = Person("Alice", department=department) - assert p.department is d - assert p.department.charge_code == "D100" - assert p.department.manager == "Charlie" + assert sut.manager == "Charlie" @pytest.mark.parametrize( @@ -59,9 +53,9 @@ def test_department_parametrized(charge_code, manager): """ 다양한 케이스에 대해 Department의 chargeCode와 manager를 설정해보는 파라미터라이즈 테스트. """ - d = Department() - d.charge_code = charge_code - d.manager = manager + department = Department() + department.charge_code = charge_code + department.manager = manager + sut = Person("Alice", department=department) - assert d.charge_code == charge_code - assert d.manager == manager + assert sut.manager == manager From e53ce46af4815166e2a803bfddccb8c82f646954 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:21:57 +0900 Subject: [PATCH 33/48] feat(chapter7): add Person and Department classes with properties and tests --- chapter07/src/case08/README.md | 58 ++++++++++++++++++++++++++ chapter07/src/case08/__init__.py | 0 chapter07/src/case08/case08.py | 45 ++++++++++++++++++++ chapter07/tests/case08/__init__.py | 0 chapter07/tests/case08/test_case08.py | 60 +++++++++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 chapter07/src/case08/README.md create mode 100644 chapter07/src/case08/__init__.py create mode 100644 chapter07/src/case08/case08.py create mode 100644 chapter07/tests/case08/__init__.py create mode 100644 chapter07/tests/case08/test_case08.py diff --git a/chapter07/src/case08/README.md b/chapter07/src/case08/README.md new file mode 100644 index 0000000..95bbb10 --- /dev/null +++ b/chapter07/src/case08/README.md @@ -0,0 +1,58 @@ +# 7.8 중재자 제거하기 + +_Remove Middle Man_ + +## 개요 + +Before + +```python +manager = a_person.manager + + +class Person: + @property + def manager(self): + return self.department.manager +``` + +After + +```python +manager = a_person.department.manager +``` + +## 배경 + +위임 숨기기(7.7절)의 배경에서는 캡슐화의 이점을 설명했다. +다만 이러면 위임 객체의 다른 기능을 쓰려고 할 때마다 서버에 위임 메소드를 둬야한다. +이게 심해지면 서버 클래스는 중개자(_middle man_)으로 전락한다. 자꾸 이렇게 되면 클라이언트가 위임 객체를 직접 부르는게 낫다. + +> ![WARNING] +> +> 이런 코드는 Law of Demeter 를 너무 신봉하면 생긴다. +> 내부정보를 과도하게 숨기면 wrapper가 늘어나는 단점이 생긴다. +> +> 상황에 맞게 사용하자! + +답이 없다보니 7.7절과 7.8절을 계속 해가며 중간점을 찾아가면 된다. +시스템이 원하는 "적절한"시점은 그때그때 바뀌므로, 때에 맞추어 리팩터링하는 힘만 있으면 된다. + +명심하자. 정답이 없다고 말하는 사람들은 "소프트웨어는 바뀜에 적응해야한다" 라는 말을 안하거나 할 필요가 없다고 해서 안한거다. + +하지만 저게 중요하다. 소프트웨어 만드는 것은 답이 없다. 그럼 변화에 적응해야하는 힘을 길러야할 뿐... + +## 절차 + +`
`, `→` 복사해서 쓰기 + +1. 위임 객체를 얻는 게터를 만든다 +2. 위임 메소드를 호출하는 클라이언트가 모두 이 게터를 거치도록 수정한다. 하나씩 바꿀 때마다 테스트한다 +3. 모두 수정했다면 위임 메소드를 삭제한다
+ → 자동 리팩터링 도구를 쓸 때는 위임 필드를 캡슐화(6.6절)한 후 이를 사용하는 모든 메소드를 인라인(6.2절)한다 + +## 예시 + +자신이 속한 부서 객체를 통해 관리자를 찾는 사람 클래스이다만, + +위임 메소드가 너무 많다 가정하고 중개자를 빼는 훈련을 해보자. \ No newline at end of file diff --git a/chapter07/src/case08/__init__.py b/chapter07/src/case08/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case08/case08.py b/chapter07/src/case08/case08.py new file mode 100644 index 0000000..cb33101 --- /dev/null +++ b/chapter07/src/case08/case08.py @@ -0,0 +1,45 @@ +# organization.py + + +class Person: + def __init__(self, name, department): + self._name = name + self._department = department + + @property + def name(self): + return self._name + + @property + def department(self): + return self._department + + @department.setter + def department(self, arg): + self._department = arg + + @property + def manager(self): + return self._department.manager + + +class Department: + def __init__(self): + self._charge_code = None + self._manager = None + + @property + def charge_code(self): + return self._charge_code + + @charge_code.setter + def charge_code(self, arg): + self._charge_code = arg + + @property + def manager(self): + return self._manager + + @manager.setter + def manager(self, arg): + self._manager = arg diff --git a/chapter07/tests/case08/__init__.py b/chapter07/tests/case08/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case08/test_case08.py b/chapter07/tests/case08/test_case08.py new file mode 100644 index 0000000..bf256d1 --- /dev/null +++ b/chapter07/tests/case08/test_case08.py @@ -0,0 +1,60 @@ +import pytest +from src.case08.case08 import ( + Person, + Department, +) + + +def test_person_basic(): + """department를 반드시 넣어야함 + + :return: + """ + with pytest.raises(TypeError): + Person("Alice") + + +def test_department_basic(): + """ + Department 객체를 생성하고, chargeCode와 manager 게터/세터가 잘 동작하는지 테스트. + """ + department = Department() + assert department.charge_code is None + assert department.manager is None + + department.charge_code = "CODE123" + department.manager = "Bob" + + assert department.charge_code == "CODE123" + assert department.manager == "Bob" + + +def test_person_and_department(): + """ + Person에 Department를 연결했을 때 정상적으로 참조가 이뤄지는지 확인. + """ + department = Department() + department.charge_code = "D100" + department.manager = "Charlie" + sut = Person("Alice", department=department) + + assert sut.manager == "Charlie" + + +@pytest.mark.parametrize( + "charge_code, manager", + [ + ("ENG-001", "Eve"), + ("SALES-007", "Mallory"), + ], +) +def test_department_parametrized(charge_code, manager): + """ + 다양한 케이스에 대해 Department의 chargeCode와 manager를 설정해보는 파라미터라이즈 테스트. + """ + department = Department() + department.charge_code = charge_code + department.manager = manager + sut = Person("Alice", department=department) + + assert sut.manager == manager From c02e06a3be5cb1b49acc5d1b9e22a0270297c2c2 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:24:01 +0900 Subject: [PATCH 34/48] refactor(chapter7): remove manager property from Person class and update tests --- chapter07/src/case08/README.md | 2 +- chapter07/src/case08/case08.py | 4 ---- chapter07/tests/case08/test_case08.py | 6 ++++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/chapter07/src/case08/README.md b/chapter07/src/case08/README.md index 95bbb10..9e716b8 100644 --- a/chapter07/src/case08/README.md +++ b/chapter07/src/case08/README.md @@ -55,4 +55,4 @@ manager = a_person.department.manager 자신이 속한 부서 객체를 통해 관리자를 찾는 사람 클래스이다만, -위임 메소드가 너무 많다 가정하고 중개자를 빼는 훈련을 해보자. \ No newline at end of file +위임 메소드가 너무 많다 가정하고 중개자를 빼는 훈련을 해보자. diff --git a/chapter07/src/case08/case08.py b/chapter07/src/case08/case08.py index cb33101..fd4305f 100644 --- a/chapter07/src/case08/case08.py +++ b/chapter07/src/case08/case08.py @@ -18,10 +18,6 @@ def department(self): def department(self, arg): self._department = arg - @property - def manager(self): - return self._department.manager - class Department: def __init__(self): diff --git a/chapter07/tests/case08/test_case08.py b/chapter07/tests/case08/test_case08.py index bf256d1..eec6cf7 100644 --- a/chapter07/tests/case08/test_case08.py +++ b/chapter07/tests/case08/test_case08.py @@ -38,7 +38,8 @@ def test_person_and_department(): department.manager = "Charlie" sut = Person("Alice", department=department) - assert sut.manager == "Charlie" + assert sut.department.manager == "Charlie" + assert sut.department.charge_code == "D100" @pytest.mark.parametrize( @@ -57,4 +58,5 @@ def test_department_parametrized(charge_code, manager): department.manager = manager sut = Person("Alice", department=department) - assert sut.manager == manager + assert sut.department.manager == manager + assert sut.department.charge_code == charge_code From 9049b7a07bf3b47f1bc2c70471832762308457e3 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:44:48 +0900 Subject: [PATCH 35/48] feat(chapter7): implement new algorithm for finding names and add tests --- chapter07/src/case09/README.md | 53 +++++++++++++++++++++++++++ chapter07/src/case09/__init__.py | 0 chapter07/src/case09/case09.py | 24 ++++++++++++ chapter07/tests/case09/__init__.py | 0 chapter07/tests/case09/test_case09.py | 52 ++++++++++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 chapter07/src/case09/README.md create mode 100644 chapter07/src/case09/__init__.py create mode 100644 chapter07/src/case09/case09.py create mode 100644 chapter07/tests/case09/__init__.py create mode 100644 chapter07/tests/case09/test_case09.py diff --git a/chapter07/src/case09/README.md b/chapter07/src/case09/README.md new file mode 100644 index 0000000..e7e4261 --- /dev/null +++ b/chapter07/src/case09/README.md @@ -0,0 +1,53 @@ +# 7.9 알고리즘 교체하기 + +_Substitute Algorithm_ + +## 개요 + +Before + +```python +def found_person(people): + for person in people: + if person == "Don": + return "Don" + if person == "John": + return "John" + if person == "Kent": + return "Kent" + return "" +``` + +After + +```python +def found_person_new(people): + candidates = ["Don", "John", "Kent"] + return next((p for p in people if p in candidates), "") +``` + +## 배경 + +목적을 달성하기 위해 쉬운 방법은 존재하게 마련이다. 알고리즘도 마찬가지다. +파울러는 더 쉬운 방법이 있으면 복잡한 코드를 고친다. 알고리즘을 모두 걷어내고 훨씬 쉬운 코드로 바꿀 때의 방법이다. +문제를 더 확실히 이해하고 더 쉬운 방법을 발견했을 때 이 방법을 쓰는 것이 좋다. 내 코드와 똑같은 라이브러리로 바꿔치기할 때도 적용할 수 있다. + +이 작업을 하려면 반드시 메소드가 가능한 한 잘게 나눈 상태인지 확인해야한다. 그래야 간소화가 수비다. 큰걸 한번에 바꾸는건 너무 어렵다. + +## 절차 + +1. 교체할 코드를 함수 하나에 모은다. +2. 이 함수만을 이용해 동작을 검증하는 테스트를 마련한다. +3. 대체할 알고리즘을 준비한다. +4. 정적 검사를 수행한다. +5. 기존 알고리즘과 새 알고리즘의 결과를 비교하는 테스트를 수행한다. 두 결과가 같다면 리팩터링이 끝난다. 그렇지 않다면 기존 알고리즘을 참고하여 새 알고리즘을 테스트하고 디버깅한다. + +## 예시 + +```python +people_list = ["Sam", "Don", "Amy"] + +result = found_person(people_list) +result = found_person_new(people_list) +print(result) # "Don" +``` \ No newline at end of file diff --git a/chapter07/src/case09/__init__.py b/chapter07/src/case09/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/src/case09/case09.py b/chapter07/src/case09/case09.py new file mode 100644 index 0000000..63b7cc6 --- /dev/null +++ b/chapter07/src/case09/case09.py @@ -0,0 +1,24 @@ +def found_person_old(people): + for person in people: + if person == "Don": + return "Don" + if person == "John": + return "John" + if person == "Kent": + return "Kent" + return "" + + +def found_person_new(people): + """ + Return the first name from people that appears in the list of candidates, + or an empty string if none are found. + + Notes: + - This function uses a generator expression (p for p in people if p in candidates) + to iterate over 'people'. + - The built-in `next()` function retrieves **the first match** from that generator. + - If no match is found, the default value "" is returned. + """ + candidates = ["Don", "John", "Kent"] + return next((p for p in people if p in candidates), "") diff --git a/chapter07/tests/case09/__init__.py b/chapter07/tests/case09/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chapter07/tests/case09/test_case09.py b/chapter07/tests/case09/test_case09.py new file mode 100644 index 0000000..745f2d6 --- /dev/null +++ b/chapter07/tests/case09/test_case09.py @@ -0,0 +1,52 @@ +import pytest +from src.case09.case09 import ( + found_person_old, + found_person_new, +) + + +@pytest.mark.parametrize( + "people, expected", + [ + ([], ""), # 빈 리스트일 때 + (["Sam", "Nick"], ""), # 후보가 없을 때 + (["Don", "John", "Kent"], "Don"), # 여러 후보가 있어도 첫 번째 발견된 이름 반환 + (["Alice", "Kent", "Bob"], "Kent"), # 중간에 Kent가 있을 때 + (["John"], "John"), # 단일 후보 + (["Don", "Kent", "John"], "Don"), # Don이 가장 먼저 오는 경우 + ], +) +def test_found_person_old(people, expected): + """found_person_old가 주어진 people 리스트에서 올바른 결과를 반환하는지 테스트""" + assert found_person_old(people) == expected + + +@pytest.mark.parametrize( + "people, expected", + [ + ([], ""), + (["Sam", "Nick"], ""), + (["Don", "John", "Kent"], "Don"), + (["Alice", "Kent", "Bob"], "Kent"), + (["John"], "John"), + (["Don", "Kent", "John"], "Don"), + ], +) +def test_found_person_new(people, expected): + """found_person_new가 주어진 people 리스트에서 올바른 결과를 반환하는지 테스트""" + assert found_person_new(people) == expected + + +@pytest.mark.parametrize( + "people, expected", + [ + ([], ""), + (["Sam", "Nick"], ""), + (["Don", "John", "Kent"], "Don"), + (["Alice", "Kent", "Bob"], "Kent"), + (["John"], "John"), + (["Don", "Kent", "John"], "Don"), + ], +) +def test_two_algorithm_same_result(people, expected): + assert found_person_old(people) == found_person_new(people) == expected From c6c9f401150c45fa7c0669252cfd7c86e26ea958 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 01:45:16 +0900 Subject: [PATCH 36/48] docs(chapter7): marked chapter 7 as complete --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d5d7eb..3d55e9c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - [x] Chapter 04 - [x] Chapter 05 - [x] Chapter 06 -- [ ] Chapter 07 +- [x] Chapter 07 - [ ] Chapter 08 - [ ] Chapter 09 - [ ] Chapter 10 @@ -22,7 +22,7 @@ - 리팩터링은 소프트웨어가 "썩지 않게" 하는 방어기제다. - 리팩터링은 동작은 그대로인데 구조를 바꾸는 것이다. - 소프트웨어니까 내부 설계를 개선할 수 있다. -- 그런데 리팩터링은 체계적이고 계획적으로 해야한다. +- 그런데 리팩터링은 체계적이고 계획적으로 해야한다. - 테스트코드가 없는 리팩터링은 효용이 없거나 매우 떨어진다. - 그렇다고 마냥 어려운 건 아니다. - 한 클래스의 필드를 다른 클래스로 옮기거나 From 50c187ec9d39dba7aee71090a2ec1cb3e92dd80d Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:15:57 +0900 Subject: [PATCH 37/48] feat(tests): add Makefile and pytest configuration for running tests across chapters --- Makefile | 24 ++++++++++++++++++++++++ pytest.ini | 9 +++++++++ 2 files changed, 33 insertions(+) create mode 100644 Makefile create mode 100644 pytest.ini diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da78d6d --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: test test-ch01 test-ch04 test-ch06 test-ch07 clean + +test-all: test-ch01 test-ch04 test-ch06 test-ch07 + +test-ch01: + cd chapter01 && pytest && cd .. + +test-ch04: + cd chapter04 && pytest && cd .. + +test-ch06: + cd chapter06 && pytest && cd .. + +test-ch07: + cd chapter07 && pytest && cd .. + +# 모든 테스트의 alias +test: test-all + +# 캐시 파일 정리 +clean: + find . -type d -name "__pycache__" -exec rm -r {} + + find . -type d -name ".pytest_cache" -exec rm -r {} + + find . -type f -name "*.pyc" -delete diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4845fd3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +testpaths = + chapter01/tests + chapter04/tests + chapter06/tests + chapter07/tests +python_files = test_*.py +pythonpath = . +norecursedirs = chapter02 chapter03 chapter05 \ No newline at end of file From 0919dc54daaf18e05a919d81f2a55c535a9fb1d4 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:35:20 +0900 Subject: [PATCH 38/48] feat(tests): add GitHub Actions workflow for automated testing and coverage reporting --- .github/workflows/pr-test.yml | 70 +++++++++++++++++++++++++++++++++++ .gitignore | 2 + Makefile | 59 ++++++++++++++++++++++------- poetry.lock | 13 ++++++- pyproject.toml | 1 + 5 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/pr-test.yml diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml new file mode 100644 index 0000000..d3d7b49 --- /dev/null +++ b/.github/workflows/pr-test.yml @@ -0,0 +1,70 @@ +name: PR Test & Coverage Report + +on: + pull_request: + branches: + - main # 필요시 변경 + +jobs: + test: + name: Run Tests & Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12.6' + + - name: Install Dependencies + run: | + python -m pip install -r requirements.txt + python -m pip install coverage junitparser + + - name: Run Tests (by Makefile) + run: | + make test + + - name: Combine Coverage Report (XML) + run: | + coverage xml -o coverage.xml + + - name: Parse Coverage and Write Summary + run: | + TOTAL=$(xmllint --xpath 'string(/coverage/@line-rate)' coverage.xml) + PERCENTAGE=$(echo "$TOTAL * 100" | bc) + echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-----|" >> $GITHUB_STEP_SUMMARY + echo "| 📊 Line Coverage | ${PERCENTAGE}% |" >> $GITHUB_STEP_SUMMARY + + - name: Publish Test Results Summary + uses: test-summary/action@v2 + with: + paths: "test-results/*.xml" + if: always() + + - name: Upload Coverage HTML Report + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: htmlcov/ + + - name: Upload JUnit XML (for archive) + uses: actions/upload-artifact@v4 + with: + name: junit-xml-reports + path: test-results/ + + - name: Add Coverage Comment to PR + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + ## 📝 Coverage Report + | Metric | Value | + |--------|-----| + | 📊 Line Coverage | ${PERCENTAGE}% | + if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 30683dd..9134a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -358,3 +358,5 @@ $RECYCLE.BIN/ ## env-related files .env* + +test-results/ diff --git a/Makefile b/Makefile index da78d6d..7564633 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,57 @@ -.PHONY: test test-ch01 test-ch04 test-ch06 test-ch07 clean +.PHONY: test test-all test-ch01 test-ch04 test-ch06 test-ch07 clean clean-coverage clean-results combine-coverage merge-junit -test-all: test-ch01 test-ch04 test-ch06 test-ch07 +CHAPTERS = ch01 ch04 ch06 ch07 +TEST_RESULTS_DIR = test-results + +test: test-all + +clean: clean-results clean-coverage + find . -type d -name "__pycache__" -exec rm -r {} + + find . -type d -name ".pytest_cache" -exec rm -r {} + + find . -type f -name "*.pyc" -delete + +clean-results: + rm -rf $(TEST_RESULTS_DIR) + +clean-coverage: + coverage erase + rm -f $(TEST_RESULTS_DIR)/.coverage* + +test-all: clean-results clean-coverage $(TEST_RESULTS_DIR) test-ch01 test-ch04 test-ch06 test-ch07 combine-coverage merge-junit test-ch01: - cd chapter01 && pytest && cd .. + cd chapter01 && \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch01 coverage run -m pytest -v \ + --junitxml=../$(TEST_RESULTS_DIR)/ch01-results.xml test-ch04: - cd chapter04 && pytest && cd .. + cd chapter04 && \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch04 coverage run -m pytest -v \ + --junitxml=../$(TEST_RESULTS_DIR)/ch04-results.xml test-ch06: - cd chapter06 && pytest && cd .. + cd chapter06 && \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch06 coverage run -m pytest -v \ + --junitxml=../$(TEST_RESULTS_DIR)/ch06-results.xml test-ch07: - cd chapter07 && pytest && cd .. + cd chapter07 && \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch07 coverage run -m pytest -v \ + --junitxml=../$(TEST_RESULTS_DIR)/ch07-results.xml -# 모든 테스트의 alias -test: test-all +combine-coverage: + coverage combine $(TEST_RESULTS_DIR) + coverage report + coverage html -# 캐시 파일 정리 -clean: - find . -type d -name "__pycache__" -exec rm -r {} + - find . -type d -name ".pytest_cache" -exec rm -r {} + - find . -type f -name "*.pyc" -delete +merge-junit: + command -v python3 > /dev/null && python3 -m pip show junitparser > /dev/null || python3 -m pip install junitparser + python3 -m junitparser merge \ + $(TEST_RESULTS_DIR)/ch01-results.xml \ + $(TEST_RESULTS_DIR)/ch04-results.xml \ + $(TEST_RESULTS_DIR)/ch06-results.xml \ + $(TEST_RESULTS_DIR)/ch07-results.xml \ + $(TEST_RESULTS_DIR)/merged-results.xml + +$(TEST_RESULTS_DIR): + mkdir -p $(TEST_RESULTS_DIR) diff --git a/poetry.lock b/poetry.lock index a08a4be..840bf63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -195,6 +195,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "junitparser" +version = "3.2.0" +description = "Manipulates JUnit/xUnit Result XML files" +optional = false +python-versions = "*" +files = [ + {file = "junitparser-3.2.0-py2.py3-none-any.whl", hash = "sha256:e14fdc0a999edfc15889b637390e8ef6ca09a49532416d3bd562857d42d4b96d"}, + {file = "junitparser-3.2.0.tar.gz", hash = "sha256:b05e89c27e7b74b3c563a078d6e055d95cf397444f8f689b0ca616ebda0b3c65"}, +] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -347,4 +358,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "a1becce150de9193a0cf3c78baa634068170d8a2634b48175a35a272e52cd68b" +content-hash = "5c4df4afd6900c1ada772ec6d97adc2367b7a665f44e3d00793c1f4f6f77d4ca" diff --git a/pyproject.toml b/pyproject.toml index 3c1cb22..617abbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ arrow = "^1.3.0" pytest = "^8.3.4" beautifulsoup4 = "^4.12.3" pytest-cov = "^6.0.0" +junitparser = "^3.2.0" [tool.poetry.group.linter.dependencies] From d2c2c59a05084455cc8bc2cc2019e4790d30c3c3 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:38:22 +0900 Subject: [PATCH 39/48] feat(tests): update GitHub Actions workflow to use Poetry for dependency management --- .github/workflows/pr-test.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d3d7b49..95da72f 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -19,10 +19,13 @@ jobs: with: python-version: '3.12.6' - - name: Install Dependencies + - name: Install Poetry run: | - python -m pip install -r requirements.txt - python -m pip install coverage junitparser + pipx install poetry + + - name: Install dependencies + run: | + poetry install --no-root - name: Run Tests (by Makefile) run: | @@ -30,7 +33,7 @@ jobs: - name: Combine Coverage Report (XML) run: | - coverage xml -o coverage.xml + poetry run coverage xml -o coverage.xml - name: Parse Coverage and Write Summary run: | From 3b101ac8c7b63dc891f275dd336f5ff29a21f25f Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:42:58 +0900 Subject: [PATCH 40/48] feat(tests): update Makefile and GitHub Actions to use Poetry for test execution and coverage --- .github/workflows/pr-test.yml | 2 +- Makefile | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 95da72f..d1d10d4 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -29,7 +29,7 @@ jobs: - name: Run Tests (by Makefile) run: | - make test + poetry run make test - name: Combine Coverage Report (XML) run: | diff --git a/Makefile b/Makefile index 7564633..9e9c1c0 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,9 @@ CHAPTERS = ch01 ch04 ch06 ch07 TEST_RESULTS_DIR = test-results +# Poetry 환경 강제 +POETRY ?= poetry run + test: test-all clean: clean-results clean-coverage @@ -14,39 +17,39 @@ clean-results: rm -rf $(TEST_RESULTS_DIR) clean-coverage: - coverage erase + $(POETRY) coverage erase rm -f $(TEST_RESULTS_DIR)/.coverage* test-all: clean-results clean-coverage $(TEST_RESULTS_DIR) test-ch01 test-ch04 test-ch06 test-ch07 combine-coverage merge-junit test-ch01: cd chapter01 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch01 coverage run -m pytest -v \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch01 $(POETRY) coverage run -m pytest -v \ --junitxml=../$(TEST_RESULTS_DIR)/ch01-results.xml test-ch04: cd chapter04 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch04 coverage run -m pytest -v \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch04 $(POETRY) coverage run -m pytest -v \ --junitxml=../$(TEST_RESULTS_DIR)/ch04-results.xml test-ch06: cd chapter06 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch06 coverage run -m pytest -v \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch06 $(POETRY) coverage run -m pytest -v \ --junitxml=../$(TEST_RESULTS_DIR)/ch06-results.xml test-ch07: cd chapter07 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch07 coverage run -m pytest -v \ + COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch07 $(POETRY) coverage run -m pytest -v \ --junitxml=../$(TEST_RESULTS_DIR)/ch07-results.xml combine-coverage: - coverage combine $(TEST_RESULTS_DIR) - coverage report - coverage html + $(POETRY) coverage combine $(TEST_RESULTS_DIR) + $(POETRY) coverage report + $(POETRY) coverage html merge-junit: - command -v python3 > /dev/null && python3 -m pip show junitparser > /dev/null || python3 -m pip install junitparser - python3 -m junitparser merge \ + $(POETRY) python -m pip show junitparser > /dev/null || $(POETRY) python -m pip install junitparser + $(POETRY) python -m junitparser merge \ $(TEST_RESULTS_DIR)/ch01-results.xml \ $(TEST_RESULTS_DIR)/ch04-results.xml \ $(TEST_RESULTS_DIR)/ch06-results.xml \ From 9b022f728b88231e7559c4165694ea673fecd8c6 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:47:08 +0900 Subject: [PATCH 41/48] feat(tests): refactor coverage summary parsing to use Python with Poetry --- .github/workflows/pr-test.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d1d10d4..b92ea32 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -37,11 +37,17 @@ jobs: - name: Parse Coverage and Write Summary run: | - TOTAL=$(xmllint --xpath 'string(/coverage/@line-rate)' coverage.xml) - PERCENTAGE=$(echo "$TOTAL * 100" | bc) - echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY - echo "|--------|-----|" >> $GITHUB_STEP_SUMMARY - echo "| 📊 Line Coverage | ${PERCENTAGE}% |" >> $GITHUB_STEP_SUMMARY + poetry run python -c " + import xml.etree.ElementTree as ET + tree = ET.parse('coverage.xml') + root = tree.getroot() + line_rate = float(root.attrib['line-rate']) + percentage = line_rate * 100 + with open('${{ github.step_summary }}', 'a') as f: + f.write('| Metric | Value |\n') + f.write('|--------|-----|\n') + f.write(f'| 📊 Line Coverage | {percentage:.2f}% |\n') + " - name: Publish Test Results Summary uses: test-summary/action@v2 From 79e1e05f76b8670443c0c23c231883173544b28f Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 02:57:23 +0900 Subject: [PATCH 42/48] feat(tests): enhance GitHub Actions workflow to parse and report coverage percentage --- .github/workflows/pr-test.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index b92ea32..75e98fb 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -23,7 +23,7 @@ jobs: run: | pipx install poetry - - name: Install dependencies + - name: Install Dependencies run: | poetry install --no-root @@ -67,6 +67,18 @@ jobs: name: junit-xml-reports path: test-results/ + - name: Parse Coverage for Comment + id: coverage-comment + run: | + poetry run python -c " + import xml.etree.ElementTree as ET + tree = ET.parse('coverage.xml') + root = tree.getroot() + line_rate = float(root.attrib['line-rate']) + percentage = line_rate * 100 + print(f'COVERAGE_PERCENT={percentage:.2f}') # GitHub Actions에서 사용할 환경 변수 출력" + shell: bash + - name: Add Coverage Comment to PR uses: peter-evans/create-or-update-comment@v4 with: @@ -75,5 +87,6 @@ jobs: ## 📝 Coverage Report | Metric | Value | |--------|-----| - | 📊 Line Coverage | ${PERCENTAGE}% | + | 📊 Line Coverage | ${{ steps.coverage-comment.outputs.COVERAGE_PERCENT }}% | + token: ${{ secrets.PAT_TOKEN }} if: github.event_name == 'pull_request' From d3f8f82acba7af823a1cbee1bee7986ac45f6b48 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 03:01:58 +0900 Subject: [PATCH 43/48] feat(tests): improve coverage parsing in GitHub Actions workflow to output percentage correctly --- .github/workflows/pr-test.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 75e98fb..e9d69bd 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -70,14 +70,14 @@ jobs: - name: Parse Coverage for Comment id: coverage-comment run: | - poetry run python -c " + percentage=$(poetry run python -c " import xml.etree.ElementTree as ET tree = ET.parse('coverage.xml') root = tree.getroot() line_rate = float(root.attrib['line-rate']) - percentage = line_rate * 100 - print(f'COVERAGE_PERCENT={percentage:.2f}') # GitHub Actions에서 사용할 환경 변수 출력" - shell: bash + print(f'{line_rate * 100:.2f}') + ") + echo "COVERAGE_PERCENT=$percentage" >> "$GITHUB_OUTPUT" - name: Add Coverage Comment to PR uses: peter-evans/create-or-update-comment@v4 @@ -89,4 +89,3 @@ jobs: |--------|-----| | 📊 Line Coverage | ${{ steps.coverage-comment.outputs.COVERAGE_PERCENT }}% | token: ${{ secrets.PAT_TOKEN }} - if: github.event_name == 'pull_request' From 0fac3abb42dba4b71452565770c3731424a7b043 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 10:19:53 +0900 Subject: [PATCH 44/48] feat(tests): enhance GitHub Actions workflow to parse and report test results and coverage summary --- .github/workflows/pr-test.yml | 75 +++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index e9d69bd..d902a08 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -3,7 +3,7 @@ name: PR Test & Coverage Report on: pull_request: branches: - - main # 필요시 변경 + - main jobs: test: @@ -35,21 +35,50 @@ jobs: run: | poetry run coverage xml -o coverage.xml - - name: Parse Coverage and Write Summary + - name: Parse Coverage for Comment + id: coverage-comment run: | - poetry run python -c " + percentage=$(poetry run python -c " import xml.etree.ElementTree as ET tree = ET.parse('coverage.xml') root = tree.getroot() line_rate = float(root.attrib['line-rate']) - percentage = line_rate * 100 - with open('${{ github.step_summary }}', 'a') as f: - f.write('| Metric | Value |\n') - f.write('|--------|-----|\n') - f.write(f'| 📊 Line Coverage | {percentage:.2f}% |\n') - " - - - name: Publish Test Results Summary + print(f'{line_rate * 100:.2f}') + ") + echo "COVERAGE_PERCENT=$percentage" >> "$GITHUB_OUTPUT" + + - name: Parse JUnit Results for Summary + id: junit-summary + run: | + summary=$(poetry run python -c " + import xml.etree.ElementTree as ET + import glob + + total_tests = 0 + total_failures = 0 + total_skipped = 0 + total_errors = 0 + total_time = 0.0 + + for file in glob.glob('test-results/*.xml'): + tree = ET.parse(file) + root = tree.getroot() + total_tests += int(root.attrib['tests']) + total_failures += int(root.attrib['failures']) + total_skipped += int(root.attrib['skipped']) + total_errors += int(root.attrib.get('errors', 0)) + total_time += float(root.attrib.get('time', 0)) + + passed = total_tests - total_failures - total_skipped + print(f'PASSED={passed}') + print(f'FAILED={total_failures}') + print(f'SKIPPED={total_skipped}') + print(f'TIME={total_time:.2f}') + ") + + echo "$summary" >> "$GITHUB_OUTPUT" + + - name: Publish Test Results Summary (optional) uses: test-summary/action@v2 with: paths: "test-results/*.xml" @@ -67,25 +96,19 @@ jobs: name: junit-xml-reports path: test-results/ - - name: Parse Coverage for Comment - id: coverage-comment - run: | - percentage=$(poetry run python -c " - import xml.etree.ElementTree as ET - tree = ET.parse('coverage.xml') - root = tree.getroot() - line_rate = float(root.attrib['line-rate']) - print(f'{line_rate * 100:.2f}') - ") - echo "COVERAGE_PERCENT=$percentage" >> "$GITHUB_OUTPUT" - - - name: Add Coverage Comment to PR + - name: Add Test & Coverage Summary Comment to PR uses: peter-evans/create-or-update-comment@v4 with: issue-number: ${{ github.event.pull_request.number }} body: | - ## 📝 Coverage Report - | Metric | Value | + ## 📝 Test & Coverage Report + + | Metric | Value | |--------|-----| + | ✅ Passed | ${{ steps.junit-summary.outputs.PASSED }} | + | ❌ Failed | ${{ steps.junit-summary.outputs.FAILED }} | + | ⏩ Skipped | ${{ steps.junit-summary.outputs.SKIPPED }} | + | ⏱️ Time | ${{ steps.junit-summary.outputs.TIME }}s | | 📊 Line Coverage | ${{ steps.coverage-comment.outputs.COVERAGE_PERCENT }}% | + token: ${{ secrets.PAT_TOKEN }} From d5589499b32b7138db35a4232a6f53b23d175af6 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 10:24:25 +0900 Subject: [PATCH 45/48] feat(tests): improve XML parsing in GitHub Actions workflow to handle multiple test suite formats --- .github/workflows/pr-test.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d902a08..0a94806 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -53,29 +53,38 @@ jobs: summary=$(poetry run python -c " import xml.etree.ElementTree as ET import glob - + total_tests = 0 total_failures = 0 total_skipped = 0 total_errors = 0 total_time = 0.0 - + for file in glob.glob('test-results/*.xml'): tree = ET.parse(file) root = tree.getroot() - total_tests += int(root.attrib['tests']) - total_failures += int(root.attrib['failures']) - total_skipped += int(root.attrib['skipped']) - total_errors += int(root.attrib.get('errors', 0)) - total_time += float(root.attrib.get('time', 0)) - + + if root.tag == 'testsuites': + suites = root.findall('testsuite') + elif root.tag == 'testsuite': + suites = [root] + else: + raise ValueError(f'Unexpected root tag: {root.tag}') + + for suite in suites: + total_tests += int(suite.attrib.get('tests', 0)) + total_failures += int(suite.attrib.get('failures', 0)) + total_skipped += int(suite.attrib.get('skipped', 0)) + total_errors += int(suite.attrib.get('errors', 0)) + total_time += float(suite.attrib.get('time', 0)) + passed = total_tests - total_failures - total_skipped print(f'PASSED={passed}') print(f'FAILED={total_failures}') print(f'SKIPPED={total_skipped}') print(f'TIME={total_time:.2f}') ") - + echo "$summary" >> "$GITHUB_OUTPUT" - name: Publish Test Results Summary (optional) From 16b400d6cca7226e19c4a2e87da565090bba9625 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 11:24:36 +0900 Subject: [PATCH 46/48] feat(tests): update GitHub Actions workflow to parse merged JUnit results and improve directory structure --- .github/workflows/pr-test.yml | 43 ++++++++++++++++---------------- Makefile | 46 +++++++++++++---------------------- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 0a94806..df81169 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -47,12 +47,22 @@ jobs: ") echo "COVERAGE_PERCENT=$percentage" >> "$GITHUB_OUTPUT" - - name: Parse JUnit Results for Summary + - name: Parse Merged JUnit Results for Summary (only merged-results.xml) id: junit-summary run: | summary=$(poetry run python -c " import xml.etree.ElementTree as ET - import glob + + tree = ET.parse('test-results/merged-results.xml') + root = tree.getroot() + + # 'testsuites' 또는 'testsuite'에 따라 동작 다름 (방어코드) + if root.tag == 'testsuites': + suites = root.findall('testsuite') + elif root.tag == 'testsuite': + suites = [root] + else: + raise ValueError(f'Unexpected root tag: {root.tag}') total_tests = 0 total_failures = 0 @@ -60,23 +70,12 @@ jobs: total_errors = 0 total_time = 0.0 - for file in glob.glob('test-results/*.xml'): - tree = ET.parse(file) - root = tree.getroot() - - if root.tag == 'testsuites': - suites = root.findall('testsuite') - elif root.tag == 'testsuite': - suites = [root] - else: - raise ValueError(f'Unexpected root tag: {root.tag}') - - for suite in suites: - total_tests += int(suite.attrib.get('tests', 0)) - total_failures += int(suite.attrib.get('failures', 0)) - total_skipped += int(suite.attrib.get('skipped', 0)) - total_errors += int(suite.attrib.get('errors', 0)) - total_time += float(suite.attrib.get('time', 0)) + for suite in suites: + total_tests += int(suite.attrib.get('tests', 0)) + total_failures += int(suite.attrib.get('failures', 0)) + total_skipped += int(suite.attrib.get('skipped', 0)) + total_errors += int(suite.attrib.get('errors', 0)) + total_time += float(suite.attrib.get('time', 0)) passed = total_tests - total_failures - total_skipped print(f'PASSED={passed}') @@ -87,10 +86,10 @@ jobs: echo "$summary" >> "$GITHUB_OUTPUT" - - name: Publish Test Results Summary (optional) + - name: Publish Merged Test Results Summary uses: test-summary/action@v2 with: - paths: "test-results/*.xml" + paths: "test-results/merged-results.xml" if: always() - name: Upload Coverage HTML Report @@ -99,7 +98,7 @@ jobs: name: coverage-html path: htmlcov/ - - name: Upload JUnit XML (for archive) + - name: Upload All JUnit XML Files (for archive) uses: actions/upload-artifact@v4 with: name: junit-xml-reports diff --git a/Makefile b/Makefile index 9e9c1c0..91484d0 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ -.PHONY: test test-all test-ch01 test-ch04 test-ch06 test-ch07 clean clean-coverage clean-results combine-coverage merge-junit +.PHONY: test test-all clean clean-coverage clean-results combine-coverage merge-junit CHAPTERS = ch01 ch04 ch06 ch07 TEST_RESULTS_DIR = test-results +COVERAGE_DIR = $(TEST_RESULTS_DIR)/coverage +JUNIT_DIR = $(TEST_RESULTS_DIR)/junit # Poetry 환경 강제 POETRY ?= poetry run @@ -18,43 +20,29 @@ clean-results: clean-coverage: $(POETRY) coverage erase - rm -f $(TEST_RESULTS_DIR)/.coverage* + rm -f $(COVERAGE_DIR)/.coverage* -test-all: clean-results clean-coverage $(TEST_RESULTS_DIR) test-ch01 test-ch04 test-ch06 test-ch07 combine-coverage merge-junit +test-all: clean-results clean-coverage $(TEST_RESULTS_DIR) run-all combine-coverage merge-junit -test-ch01: - cd chapter01 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch01 $(POETRY) coverage run -m pytest -v \ - --junitxml=../$(TEST_RESULTS_DIR)/ch01-results.xml - -test-ch04: - cd chapter04 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch04 $(POETRY) coverage run -m pytest -v \ - --junitxml=../$(TEST_RESULTS_DIR)/ch04-results.xml - -test-ch06: - cd chapter06 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch06 $(POETRY) coverage run -m pytest -v \ - --junitxml=../$(TEST_RESULTS_DIR)/ch06-results.xml - -test-ch07: - cd chapter07 && \ - COVERAGE_FILE=../$(TEST_RESULTS_DIR)/.coverage.ch07 $(POETRY) coverage run -m pytest -v \ - --junitxml=../$(TEST_RESULTS_DIR)/ch07-results.xml +run-all: + @for chapter in $(CHAPTERS); do \ + echo "Running tests for $$chapter..."; \ + (cd $$chapter && \ + COVERAGE_FILE=../$(COVERAGE_DIR)/.coverage.$$chapter \ + $(POETRY) coverage run -m pytest -v \ + --junitxml=../$(JUNIT_DIR)/$$chapter-results.xml) || exit $$?; \ + done combine-coverage: - $(POETRY) coverage combine $(TEST_RESULTS_DIR) + $(POETRY) coverage combine $(COVERAGE_DIR) $(POETRY) coverage report $(POETRY) coverage html merge-junit: $(POETRY) python -m pip show junitparser > /dev/null || $(POETRY) python -m pip install junitparser $(POETRY) python -m junitparser merge \ - $(TEST_RESULTS_DIR)/ch01-results.xml \ - $(TEST_RESULTS_DIR)/ch04-results.xml \ - $(TEST_RESULTS_DIR)/ch06-results.xml \ - $(TEST_RESULTS_DIR)/ch07-results.xml \ - $(TEST_RESULTS_DIR)/merged-results.xml + $(JUNIT_DIR)/*.xml \ + $(TEST_RESULTS_DIR)/merged-results.xml $(TEST_RESULTS_DIR): - mkdir -p $(TEST_RESULTS_DIR) + mkdir -p $(COVERAGE_DIR) $(JUNIT_DIR) From 89114f3466ecedb66de12362060f801f97582502 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 11:26:55 +0900 Subject: [PATCH 47/48] fix(makefile): update chapter names in Makefile for consistency --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 91484d0..f7e114f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: test test-all clean clean-coverage clean-results combine-coverage merge-junit -CHAPTERS = ch01 ch04 ch06 ch07 +CHAPTERS = chapter01 chapter04 chapter06 chapter07 TEST_RESULTS_DIR = test-results COVERAGE_DIR = $(TEST_RESULTS_DIR)/coverage JUNIT_DIR = $(TEST_RESULTS_DIR)/junit From 12d6e163b8968e10846e02721a531edc3f68ffa8 Mon Sep 17 00:00:00 2001 From: s3ich4n Date: Thu, 6 Mar 2025 21:55:54 +0900 Subject: [PATCH 48/48] feat(chapter7): add tests for Order priority setter and validation --- chapter07/tests/case03/test_case03.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/chapter07/tests/case03/test_case03.py b/chapter07/tests/case03/test_case03.py index 1e3dba9..7798d6a 100644 --- a/chapter07/tests/case03/test_case03.py +++ b/chapter07/tests/case03/test_case03.py @@ -62,3 +62,23 @@ def test_priority_comparison(): order2 = Order(data2) # Priority 클래스 리팩토링 후 아래 테스트 활성화 assert order1.priority.higher_than(order2.priority) + + +def test_order_priority_setter(): + # 문자열로 priority 설정 테스트 + order = Order({"priority": "normal", "product": "Widget"}) + order.priority = "high" + assert order.priority == Priority("high") + + # Priority 객체로 priority 설정 테스트 + new_priority = Priority("rush") + order.priority = new_priority + assert order.priority == Priority("rush") + assert order.priority == new_priority # 참조 동일성이 아닌 값 동일성 확인 + + # 잘못된 값에 대한 예외 발생 테스트 + try: + order.priority = "invalid" + assert False, "잘못된 우선순위 값이 허용되었습니다" + except ValueError: + pass # 예상대로 예외 발생