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