diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml
new file mode 100644
index 0000000..df81169
--- /dev/null
+++ b/.github/workflows/pr-test.yml
@@ -0,0 +1,122 @@
+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 Poetry
+ run: |
+ pipx install poetry
+
+ - name: Install Dependencies
+ run: |
+ poetry install --no-root
+
+ - name: Run Tests (by Makefile)
+ run: |
+ poetry run make test
+
+ - name: Combine Coverage Report (XML)
+ run: |
+ poetry run coverage xml -o coverage.xml
+
+ - 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: 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
+
+ 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
+ total_skipped = 0
+ total_errors = 0
+ total_time = 0.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}')
+ print(f'FAILED={total_failures}')
+ print(f'SKIPPED={total_skipped}')
+ print(f'TIME={total_time:.2f}')
+ ")
+
+ echo "$summary" >> "$GITHUB_OUTPUT"
+
+ - name: Publish Merged Test Results Summary
+ uses: test-summary/action@v2
+ with:
+ paths: "test-results/merged-results.xml"
+ if: always()
+
+ - name: Upload Coverage HTML Report
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-html
+ path: htmlcov/
+
+ - name: Upload All JUnit XML Files (for archive)
+ uses: actions/upload-artifact@v4
+ with:
+ name: junit-xml-reports
+ path: test-results/
+
+ - 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: |
+ ## 📝 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 }}
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
new file mode 100644
index 0000000..f7e114f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+.PHONY: test test-all clean clean-coverage clean-results combine-coverage merge-junit
+
+CHAPTERS = chapter01 chapter04 chapter06 chapter07
+TEST_RESULTS_DIR = test-results
+COVERAGE_DIR = $(TEST_RESULTS_DIR)/coverage
+JUNIT_DIR = $(TEST_RESULTS_DIR)/junit
+
+# Poetry 환경 강제
+POETRY ?= poetry run
+
+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:
+ $(POETRY) coverage erase
+ rm -f $(COVERAGE_DIR)/.coverage*
+
+test-all: clean-results clean-coverage $(TEST_RESULTS_DIR) run-all combine-coverage merge-junit
+
+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 $(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 \
+ $(JUNIT_DIR)/*.xml \
+ $(TEST_RESULTS_DIR)/merged-results.xml
+
+$(TEST_RESULTS_DIR):
+ mkdir -p $(COVERAGE_DIR) $(JUNIT_DIR)
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 @@
- 리팩터링은 소프트웨어가 "썩지 않게" 하는 방어기제다.
- 리팩터링은 동작은 그대로인데 구조를 바꾸는 것이다.
- 소프트웨어니까 내부 설계를 개선할 수 있다.
-- 그런데 리팩터링은 체계적이고 계획적으로 해야한다.
+- 그런데 리팩터링은 체계적이고 계획적으로 해야한다.
- 테스트코드가 없는 리팩터링은 효용이 없거나 매우 떨어진다.
- 그렇다고 마냥 어려운 건 아니다.
- 한 클래스의 필드를 다른 클래스로 옮기거나
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
+
+```
+
## 배경
## 절차
diff --git a/chapter07/README.md b/chapter07/README.md
new file mode 100644
index 0000000..ea92cad
--- /dev/null
+++ b/chapter07/README.md
@@ -0,0 +1,43 @@
+# 7장 - 캡슐화
+
+모듈 분리의 가장 중요한 기준은 비밀을 잘 숨기는 것.
+
+데이터 구조는 아래 기법으로 숨길 수 있다:
+- 레코드 캡슐화하기(7.1절)
+- 컬렉션 캡슐화하기(7.2절)
+- 기본형을 객체로 바꾸기(7.3절)
+
+클래스에서는 임시변수 처리가 골치아픈데 이거는 임시변수를 질의로 바꾸기(7.4절)로 해결
+
+클래스는 정보를 숨기는 용도로 설계되었으므로, 이를 위한 기법을 소개한다.
+- 여러 함수를 클래스로 묶기(6.9절)
+- 추출하기/인라인하기의 클래스 버전 소개
+ - 클래스 추출하기(7.5절)
+ - 클래스 인라인하기(7.6절)
+
+클래스는 내부 정보 뿐 아니라 클래스간 연결관계를 숨기는 데도 유용하다.
+- 위임 숨기기(7.7절)
+- 중개자 제거하기(7.8절)
+
+클래스 뿐 아니라 함수도 구현을 캡슐화하는 개념이다. 알고리즘을 통째로 바꾸기 위해선
+- 함수 추출하기(6.1절)로 알고리즘을 함수에 담고
+- 알고리즘 교체하기(7.9절)을 적용
+
+# Appendix I. 레코드가 뭐지?
+
+데이터 중심으로 값을 담고있는 객체. 비즈니스 로직이 없고, 데이터를 담기만 한다.
+
+데이터 레코드는 그냥 값만 담고있다가, 클래스화를 통해서 강력한 객체를 발전시킬 수 있다. 이미 6장에서 겪어봤듯.
+
+책에서는 이런 개념들로 리팩터하기를 권한다.
+
+1. "레코드 캡슐화하기(Encapsulate Record)" - 단순 데이터 구조를 객체로 변환하여 데이터 접근을 제어하는 방법
+2. "컬렉션 캡슐화하기(Encapsulate Collection)" - 컬렉션 데이터를 직접 조작하는 대신 객체를 통해 안전하게 조작하도록 변경
+3. "클래스 추출하기(Extract Class)" - 관련 데이터와 동작을 새로운 클래스로 분리
+4. "함수 옮기기(Move Function)" - 로직을 적절한 도메인 객체로 이동
+
+이러면 아래 이점을 얻을 수 있다:
+
+- 데이터는 필요할 때만 읽고/쓰게 "캡슐화"
+- 비즈니스 로직은 진짜 책임이 있는 "도메인 객체"로 이동
+- 데이터 레코드는 순수 데이터 보관소로 유지하고, 로직은 도메인 모델이 책임지게
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/README.md b/chapter07/src/case01/README.md
new file mode 100644
index 0000000..7bacbdf
--- /dev/null
+++ b/chapter07/src/case01/README.md
@@ -0,0 +1,110 @@
+# 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) - 간단한 레코드 캡슐화하기
+
+상수 캡슐화는 여기서한다 (fb38e282e65613245fb88f6c172887d570fc4003)
+
+이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다 (d9f0aed76d8ef866453f1d06a349d326423300c7)
+
+- 레코드를 클래스로 바꾸기
+- 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다
+
+`data`를 풀어서 쓸 수도 있다 (b0e1643fa61f538872b175ae7ed2ceb6421fb91f)
+
+## 예시 (2) - 중첩된 레코드 캡슐화하기
+
+...??
+
+이게 캡슐화된거라고?
\ No newline at end of file
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/src/case01/case01_1.py b/chapter07/src/case01/case01_1.py
new file mode 100644
index 0000000..f8e55d2
--- /dev/null
+++ b/chapter07/src/case01/case01_1.py
@@ -0,0 +1,10 @@
+class Organization:
+ def __init__(self, data):
+ self.name = data["name"]
+ self.country = data["country"]
+
+
+# client 1
+organization = Organization({"name": "Acme Gooseberries", "country": "GB"})
+result = f"
{organization.name}
"
+organization.name = "new name"
diff --git a/chapter07/src/case01/case01_2.py b/chapter07/src/case01/case01_2.py
new file mode 100644
index 0000000..d6212c3
--- /dev/null
+++ b/chapter07/src/case01/case01_2.py
@@ -0,0 +1,76 @@
+from copy import deepcopy
+
+
+class CustomerData:
+ def __init__(self, data):
+ self._data = data
+
+ 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 customer_data
+
+
+def get_raw_data_of_customers():
+ return customer_data.get_raw_data()
+
+
+# 예제 데이터 초기화
+raw_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
+ },
+}
+
+# 데이터 초기화
+initialize_data(raw_data)
+
+
+# 이제 compare_usage는 CustomerData 클래스의 메서드를 사용합니다
+def compare_usage(customer_id, later_year, month):
+ return get_customer_data().compare_usage(customer_id, later_year, month)
diff --git a/chapter07/src/case02/README.md b/chapter07/src/case02/README.md
new file mode 100644
index 0000000..d6bf689
--- /dev/null
+++ b/chapter07/src/case02/README.md
@@ -0,0 +1,68 @@
+# 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. 테스트한다.
+
+## 예시
+
+컬렉션 접근 제어를 막는게 캡슐화지, private으로 설정했다고 다가 아니다.
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..69c150a
--- /dev/null
+++ b/chapter07/src/case02/case02.py
@@ -0,0 +1,46 @@
+import copy
+
+
+class Person:
+ def __init__(self, name):
+ self._name = name
+ self._courses = []
+
+ @property
+ def name(self):
+ return self._name
+
+ @property
+ def courses(self):
+ 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 = copy.copy(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/src/case03/README.md b/chapter07/src/case03/README.md
new file mode 100644
index 0000000..6ffa091
--- /dev/null
+++ b/chapter07/src/case03/README.md
@@ -0,0 +1,57 @@
+# 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에서 변수를 캡슐화하며 만든 세터
+[^2]: 단계 1에서 변수를 캡슐화하며 만든 게터
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..bf0fb69
--- /dev/null
+++ b/chapter07/src/case03/case03.py
@@ -0,0 +1,39 @@
+# order.py
+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)
+
+ def __str__(self):
+ 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 = Priority(data.get("priority", "normal"))
+ self._product = data.get("product")
+ self._quantity = data.get("quantity", 0)
+ self._buyer = data.get("buyer")
+
+ @property
+ def priority(self):
+ return self._priority
+
+ @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.higher_than(Priority("normal"))]
+ )
diff --git a/chapter07/src/case04/README.md b/chapter07/src/case04/README.md
new file mode 100644
index 0000000..3c88d7b
--- /dev/null
+++ b/chapter07/src/case04/README.md
@@ -0,0 +1,58 @@
+# 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..0b34594
--- /dev/null
+++ b/chapter07/src/case04/case04.py
@@ -0,0 +1,25 @@
+class Order:
+ def __init__(self, quantity, item):
+ self._quantity = quantity
+ self._item = item
+
+ @property
+ def base_price(self):
+ return self._quantity * self._item.price
+
+ @property
+ def discount_factor(self):
+ discount_factor = 0.98
+ if self.base_price > 1000:
+ discount_factor -= 0.03
+
+ return discount_factor
+
+ @property
+ def price(self):
+ return self.base_price * self.discount_factor
+
+
+class Item:
+ def __init__(self, price):
+ self.price = price
diff --git a/chapter07/src/case05/README.md b/chapter07/src/case05/README.md
new file mode 100644
index 0000000..8d7092a
--- /dev/null
+++ b/chapter07/src/case05/README.md
@@ -0,0 +1,83 @@
+# 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절)를 적용할지 고민한다.
+
+## 예시
+
+전화번호만 별도로 빼보자. (07ebffb8c26d91622dc93de7ecb16c7b8fe1e5e9)
+
+뺀 이름에서 포괄적인 요소를 바꾸고 테스트코드도 갈아엎는다. (f0797e280a91562fe474e00aa2aa303e36c8bacb)
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..a0a2558
--- /dev/null
+++ b/chapter07/src/case05/case05.py
@@ -0,0 +1,32 @@
+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
+
+ @property
+ def telephone_number(self):
+ return str(self._telephone_number)
+
+
+class TelephoneNumber:
+ 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/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..f3547e3
--- /dev/null
+++ b/chapter07/src/case06/case06.py
@@ -0,0 +1,39 @@
+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
+
+ @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
+
+ def display(self):
+ return f"{self.shipping_company}: {self.tracking_number}"
diff --git a/chapter07/src/case07/README.md b/chapter07/src/case07/README.md
new file mode 100644
index 0000000..d2554d8
--- /dev/null
+++ b/chapter07/src/case07/README.md
@@ -0,0 +1,45 @@
+# 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. 테스트한다
+
+## 예시
+
+클라이언트에서 어떤 사람이 속한 부서의 관리자를 알고싶다고 하자.
\ No newline at end of file
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..cb33101
--- /dev/null
+++ b/chapter07/src/case07/case07.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/src/case08/README.md b/chapter07/src/case08/README.md
new file mode 100644
index 0000000..9e716b8
--- /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절)한다
+
+## 예시
+
+자신이 속한 부서 객체를 통해 관리자를 찾는 사람 클래스이다만,
+
+위임 메소드가 너무 많다 가정하고 중개자를 빼는 훈련을 해보자.
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..fd4305f
--- /dev/null
+++ b/chapter07/src/case08/case08.py
@@ -0,0 +1,41 @@
+# 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
+
+
+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/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/__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
diff --git a/chapter07/tests/case01/test_case01_2.py b/chapter07/tests/case01/test_case01_2.py
new file mode 100644
index 0000000..d5e7d37
--- /dev/null
+++ b/chapter07/tests/case01/test_case01_2.py
@@ -0,0 +1,51 @@
+from copy import deepcopy
+
+import pytest
+from src.case01.case01_2 import (
+ compare_usage,
+ get_customer_data,
+ initialize_data,
+ CustomerData,
+ raw_data,
+)
+
+
+# 각 테스트 전에 깨끗한 데이터로 시작
+@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(setup_data):
+ # 전역 함수 테스트
+ 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
+
+
+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
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..3a80e30
--- /dev/null
+++ b/chapter07/tests/case02/test_case02.py
@@ -0,0 +1,39 @@
+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)
+
+ 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"
+
+
+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),
+ ]
+ for course in courses:
+ person.add_course(course)
+
+ assert person.get_num_advanced_courses() == 2
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..7798d6a
--- /dev/null
+++ b/chapter07/tests/case03/test_case03.py
@@ -0,0 +1,84 @@
+# test_order.py
+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 == 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 == 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
+
+
+# 나중의 리팩토링을 위한 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)
+
+
+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 # 예상대로 예외 발생
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
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..9d17e5d
--- /dev/null
+++ b/chapter07/tests/case05/test_case05.py
@@ -0,0 +1,32 @@
+# tests/test_person.py
+
+import pytest
+from src.case05.case05 import (
+ Person,
+ TelephoneNumber,
+)
+
+
+def test_person_initialization():
+ """Person 클래스가 정상적으로 초기화되는지 테스트합니다."""
+ office_area_code = "010"
+ office_number = "1234-5678"
+ person = Person(TelephoneNumber(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_telephone_multiple_cases(area_code, number):
+ """다양한 케이스에 대한 Telephone 초기화 및 프로퍼티 검증."""
+ telephone = TelephoneNumber(area_code, number)
+ assert telephone.area_code == area_code
+ assert telephone.number == number
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..16a0a3d
--- /dev/null
+++ b/chapter07/tests/case06/test_case06.py
@@ -0,0 +1,60 @@
+# tests/test_tracking.py
+import pytest
+from src.case06.case06 import (
+ Shipment,
+)
+
+
+@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 테스트
+ """
+ shipment = Shipment()
+ shipment.shipping_company = company
+ shipment.tracking_number = number
+
+ assert shipment.display() == expected_display
+
+
+def test_shipment_basic():
+ """
+ Shipment의 요소가 제대로 포함되어있나 확인
+ """
+ shipment = Shipment()
+
+ # 기본적으로 None인 경우 확인
+ assert shipment.tracking_info is None
+
+ shipment.shipping_company = "FedEx"
+ shipment.tracking_number = "654321"
+
+ # Shipment에 세팅
+ assert shipment.display() == "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()
+
+ shipment.shipping_company = company
+ shipment.tracking_number = number
+
+ assert shipment.display() == expected_info
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..a083fc5
--- /dev/null
+++ b/chapter07/tests/case07/test_case07.py
@@ -0,0 +1,61 @@
+# tests/test_organization.py
+import pytest
+from src.case07.case07 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
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..eec6cf7
--- /dev/null
+++ b/chapter07/tests/case08/test_case08.py
@@ -0,0 +1,62 @@
+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.department.manager == "Charlie"
+ assert sut.department.charge_code == "D100"
+
+
+@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.department.manager == manager
+ assert sut.department.charge_code == charge_code
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
diff --git a/poetry.lock b/poetry.lock
index 0eaddda..840bf63 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"
@@ -120,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"
@@ -204,6 +290,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 +358,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
-content-hash = "364ccf2edcc23ac4f7a3fe587a4542e6bb223dce0899a2d7ba28349c6b96d45b"
+content-hash = "5c4df4afd6900c1ada772ec6d97adc2367b7a665f44e3d00793c1f4f6f77d4ca"
diff --git a/pyproject.toml b/pyproject.toml
index 1d00083..617abbd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,8 @@ arrow = "^1.3.0"
[tool.poetry.group.test.dependencies]
pytest = "^8.3.4"
beautifulsoup4 = "^4.12.3"
+pytest-cov = "^6.0.0"
+junitparser = "^3.2.0"
[tool.poetry.group.linter.dependencies]
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