Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
a1fa4a2
docs(chapter7): add content for encapsulation chapter
s3ich4n Mar 2, 2025
747a718
docs(TEMPLATES): add before and after code sections for clarity
s3ich4n Mar 3, 2025
79d61b7
feat(chapter7): add encapsulation example and background information
s3ich4n Mar 3, 2025
fb38e28
refactor(chapter7): encapsulate organization data retrieval in a func…
s3ich4n Mar 3, 2025
d9f0aed
refactor(chapter7): encapsulate organization data in a class and upda…
s3ich4n Mar 3, 2025
b0e1643
refactor(chapter7): improve organization class by adding attributes a…
s3ich4n Mar 3, 2025
1bf5149
docs(chapter7): update encapsulation section for clarity and complete…
s3ich4n Mar 3, 2025
82ccafd
refactor(chapter7): rename files and add examples for customer data e…
s3ich4n Mar 3, 2025
9701955
refactor(chapter7): enhance customer data structure and add usage tra…
s3ich4n Mar 3, 2025
256f2c6
refactor(chapter7): encapsulate customer data handling in a class and…
s3ich4n Mar 3, 2025
4e13fab
refactor(chapter7): move set_usage function into CustomerData class f…
s3ich4n Mar 3, 2025
daf79c7
refactor(chapter7): improve CustomerData class by encapsulating data …
s3ich4n Mar 3, 2025
aff4caf
refactor(chapter7): add compare_usage method to CustomerData class an…
s3ich4n Mar 3, 2025
7c20d93
feat(dependencies): add coverage and pytest-cov packages for improved…
s3ich4n Mar 3, 2025
96ffe9a
feat(chapter7): add Person and Course classes with encapsulated cours…
s3ich4n Mar 4, 2025
6b6f332
feat(chapter7): enhance Person class with course management methods
s3ich4n Mar 4, 2025
14bd0fc
feat(chapter7): add Order class and related tests for order management
s3ich4n Mar 4, 2025
fbc30ff
refactor(chapter7): encapsulate priority handling in Priority class a…
s3ich4n Mar 4, 2025
ba71786
feat(chapter7): refactor Priority class for improved priority handlin…
s3ich4n Mar 4, 2025
079446e
feat(chapter7): implement Order and Item classes with price calculati…
s3ich4n Mar 4, 2025
1a0fe55
feat(chapter7): add base_price property to calculate item price befor…
s3ich4n Mar 4, 2025
54d9a7e
fix(chapter7): correct price calculation to use instance base_price d…
s3ich4n Mar 4, 2025
966d400
refactor(chapter7): separate discount factor calculation from price p…
s3ich4n Mar 4, 2025
6593f3c
docs(chapter7): add section on class extraction with examples and pro…
s3ich4n Mar 5, 2025
a4852cf
feat(chapter7): add Person class with office area code and number pro…
s3ich4n Mar 5, 2025
07ebffb
feat(chapter7): refactor Person class to use TelephoneNumber for offi…
s3ich4n Mar 5, 2025
f0797e2
refactor(chapter7): refactor Person class to use TelephoneNumber for …
s3ich4n Mar 5, 2025
b458c25
docs(chapter7): update examples and clarify changes in naming and tes…
s3ich4n Mar 5, 2025
fe92998
feat(chapter7): add TrackingInformation and Shipment classes with bas…
s3ich4n Mar 5, 2025
d3bebc5
refactor(chapter7): replace TrackingInformation with Shipment class a…
s3ich4n Mar 5, 2025
6fe5561
feat(chapter7): add Person and Department classes with basic function…
s3ich4n Mar 5, 2025
ec43be0
refactor(chapter7): update Person class to require department and add…
s3ich4n Mar 5, 2025
e53ce46
feat(chapter7): add Person and Department classes with properties and…
s3ich4n Mar 5, 2025
c02e06a
refactor(chapter7): remove manager property from Person class and upd…
s3ich4n Mar 5, 2025
9049b7a
feat(chapter7): implement new algorithm for finding names and add tests
s3ich4n Mar 5, 2025
c6c9f40
docs(chapter7): marked chapter 7 as complete
s3ich4n Mar 5, 2025
50c187e
feat(tests): add Makefile and pytest configuration for running tests …
s3ich4n Mar 5, 2025
0919dc5
feat(tests): add GitHub Actions workflow for automated testing and co…
s3ich4n Mar 5, 2025
d2c2c59
feat(tests): update GitHub Actions workflow to use Poetry for depende…
s3ich4n Mar 5, 2025
3b101ac
feat(tests): update Makefile and GitHub Actions to use Poetry for tes…
s3ich4n Mar 5, 2025
9b022f7
feat(tests): refactor coverage summary parsing to use Python with Poetry
s3ich4n Mar 5, 2025
79e1e05
feat(tests): enhance GitHub Actions workflow to parse and report cove…
s3ich4n Mar 5, 2025
d3f8f82
feat(tests): improve coverage parsing in GitHub Actions workflow to o…
s3ich4n Mar 5, 2025
0fac3ab
feat(tests): enhance GitHub Actions workflow to parse and report test…
s3ich4n Mar 6, 2025
d558949
feat(tests): improve XML parsing in GitHub Actions workflow to handle…
s3ich4n Mar 6, 2025
16b400d
feat(tests): update GitHub Actions workflow to parse merged JUnit res…
s3ich4n Mar 6, 2025
89114f3
fix(makefile): update chapter names in Makefile for consistency
s3ich4n Mar 6, 2025
12d6e16
feat(chapter7): add tests for Order priority setter and validation
s3ich4n Mar 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,5 @@ $RECYCLE.BIN/

## env-related files
.env*

test-results/
48 changes: 48 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- [x] Chapter 04
- [x] Chapter 05
- [x] Chapter 06
- [ ] Chapter 07
- [x] Chapter 07
- [ ] Chapter 08
- [ ] Chapter 09
- [ ] Chapter 10
Expand All @@ -22,7 +22,7 @@
- 리팩터링은 소프트웨어가 "썩지 않게" 하는 방어기제다.
- 리팩터링은 동작은 그대로인데 구조를 바꾸는 것이다.
- 소프트웨어니까 내부 설계를 개선할 수 있다.
- 그런데 리팩터링은 체계적이고 계획적으로 해야한다.
- 그런데 리팩터링은 체계적이고 계획적으로 해야한다.
- 테스트코드가 없는 리팩터링은 효용이 없거나 매우 떨어진다.
- 그렇다고 마냥 어려운 건 아니다.
- 한 클래스의 필드를 다른 클래스로 옮기거나
Expand Down
12 changes: 12 additions & 0 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## 개요

Before

```python

```

After

```python

```

## 배경

## 절차
Expand Down
43 changes: 43 additions & 0 deletions chapter07/README.md
Original file line number Diff line number Diff line change
@@ -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)" - 로직을 적절한 도메인 객체로 이동

이러면 아래 이점을 얻을 수 있다:

- 데이터는 필요할 때만 읽고/쓰게 "캡슐화"
- 비즈니스 로직은 진짜 책임이 있는 "도메인 객체"로 이동
- 데이터 레코드는 순수 데이터 보관소로 유지하고, 로직은 도메인 모델이 책임지게
Empty file added chapter07/src/__init__.py
Empty file.
110 changes: 110 additions & 0 deletions chapter07/src/case01/README.md
Original file line number Diff line number Diff line change
@@ -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에서 만든 새 함수로 갈아끼운다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없으면 추가한다. 한 부분을 바꿀 때마다
테스트한다. <br />
→ 중첩된 구조처럼 복잡한 레코드면 데이터 갱신하는 클라이언트에 주의해서 살펴본다. 클라이언트가 데이터를 읽기만 한다면 데이터의 복제본이나 읽기 전용 프록시를 반환할 건지 고려 필요
6. 클래스에서 원본 데이터를 반환하는 접근자와 (1에서 검색하기 쉬운 이름을 붙여둔) 원본 레코드를 반환하는 함수를 제거한다
7. 테스트한다
8. 레코드의 필드에도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슈로하하기(7.2절)를 재귀적으로 적용한다.

## 예시 (1) - 간단한 레코드 캡슐화하기

상수 캡슐화는 여기서한다 (fb38e282e65613245fb88f6c172887d570fc4003)

이렇게 하면 변수 자체는 물론, 조작방식도 바뀐다 (d9f0aed76d8ef866453f1d06a349d326423300c7)

- 레코드를 클래스로 바꾸기
- 새 클래스의 인스턴스를 반환하는 함수를 새로 만든다

`data`를 풀어서 쓸 수도 있다 (b0e1643fa61f538872b175ae7ed2ceb6421fb91f)

## 예시 (2) - 중첩된 레코드 캡슐화하기

...??

이게 캡슐화된거라고?
Empty file.
10 changes: 10 additions & 0 deletions chapter07/src/case01/case01_1.py
Original file line number Diff line number Diff line change
@@ -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"<h1>{organization.name}</h1>"
organization.name = "new name"
Loading