막간: 결합과 추상화
결합도 낮추기
- 두 컴포넌트가 서로 아는게 많아지면 결합도가 높아진다.
- 한쪽을 변경했을 때 의존하는 다른 컴포넌트도 변경이 필요하다.
- 추상화는 세부구현을 가려 결합도를 낮츨 수 있다.
- 반대로 각 컴포넌트별 응집도를 높혔다고 할 수 있다.
디렉토리 sync 하기
- 추상화에 대한 예시로 디렉토리 sync 하는 애플리케이션을 예로 든다.
- 애플리케이션 핵심 로직은 이러하다.
- 원본에 파일이 있지만 사본에 없으면 파일을 원본에서 사본으로 복사한다.
- 원본에 파일이 있지만 사본에 있는 (내용이 같은) 파일과 이름이 다르면 사본의 파일을 원본 파일 이름과 같게 변경한다.
- 사본에 파일이 있지만 원본에는 없다면 사본의 파일을 삭제한다.
- 디렉토리 내 파일 비교를 위해 hash 함수가 필요하다.
BLOCKSIZE = 65536
def hash_file(path):
hasher = hashlib.sha1()
with path.open("rb") as file:
buf = file.read(BLOCKSIZE)
while buf:
hasher.update(buf)
buf = file.read(BLOCKSIZE)
return hasher.hexdigest()
def sync(source, dest):
source_hashes = {}
for folder, _, files in os.walk(source):
for fn in files:
source_hashes[hash_file(Path(folder) / fn)] = fn
seen = set()
for folder, _, files in os.walk(dest):
for fn in files:
dest_path = Path(folder) / fn
dest_hash = hash_file(dest_path)
seen.add(dest_hash)
if dest_hash not in source_hashes:
dest_path.remove()
elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])
for src_hash, fn in source_hashes.items():
if src_hash not in seen:
shutil.copy(Path(source) / fn, Path(dest) / fn)
- 위 구현의 단점
- 위 코드를 유닛 테스트하려면 I/O 를 피할 수 없다.
- 테스트 코드 내에서 실제 디렉토리 생성 및 파일 생성으로 환경을 만들어주고 함수를 수행해봐야할 것이다.
- 테스트 코드에서 실제 I/O가 일어나는 건 가급적 피해야한다. 테스트 시간을 늘릴뿐이며 비결정성이 높아진다.
--dry-run
플래그를 구현한다면? 변경을 피할 수 없다. 확장성이 좋지 않다.
Functional Core, Imperative Shell
- 유닛 테스트에서 I/O는 피해야한다.
- 이를 위해 I/O(Imperative)와 실제 핵심로직(Functional Core)를 분리, 핵심로직만 테스트를 수행할 수 있다.
- 실제 I/O와 함께 하는 테스트는 통합/인수 테스트에서 커버하고 많은 부분은 핵심로직 유닛테스트 작성하기로 한다.
def sync(source, dest):
source_hashes = read_paths_and_hashes(source)
dest_hashes = read_paths_and_hashes(dest)
actions = determine_actions(source_hashes, dest_hashes, source, dest)
for action, *paths in actions:
if action == "COPY":
shutil.copyfile(*paths)
if action == "MOVE":
shutil.move(*paths)
if action == "DELETE":
os.remove(paths[0])
- 아래와 같이
determine_actions
로 핵심로직을 I/O 없이 검증할 수 있다.
def test_when_a_file_exists_in_the_source_but_not_the_destination():
source_hashes = {"hash1": "fn1"}
dest_hashes = {}
actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
assert list(actions) == [("COPY", Path("/src/fn1"), Path("/dst/fn1"))]
def test_when_a_file_has_been_renamed_in_the_source():
source_hashes = {"hash1": "fn1"}
dest_hashes = {"hash1": "fn2"}
actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
assert list(actions) == [("MOVE", Path("/dst/fn2"), Path("/dst/fn1"))]
추상화
Functional Core, Imperative Shell
를 통해 핵심로직에 대한 유닛테스트는 작성할 수 있었다.
- 여전히 구체적인
shutil
, os
를 사용하며 --dry-run
플래그를 구현하기엔 확장성이 좋지않다.
- I/O와 연관있는 부분은 추상화할 수 있다.
class FakeFileSystem(list):
def copy(self, src, dest):
self.append(('COPY', src, dest))
def move(self, src, dest):
self.append(('MOVE', src, dest))
def delete(self, dest):
self.append(('DELETE', dest))
def sync(reader, filesystem, source_root, dest_root):
source_hashes = reader(source_root)
dest_hashes = reader(dest_root)
for sha, filename in source_hashes.items():
if sha not in dest_hashes:
sourcepath = source_root / filename
destpath = dest_root / filename
filesystem.copy(destpath, sourcepath)
elif dest_hashes[sha] != filename:
olddestpath = dest_root / dest_hashes[sha]
newdestpath = dest_root / filename
filesystem.move(olddestpath, newdestpath)
for sha, filename in dest_hashes.items():
if sha not in source_hashes:
filesystem.delete(dest_root / filename)
reader
와 filesystem
으로 추상화를 시켰고 테스트에서는 Fake 를 사용할 수 있어진다. (I/O 없는 유닛테스트)
--dry-run
플래그용 filesystem
구현체를 만들어서 넣어주면 sync(...)
코드 변경 없이 구현할 수 있다.
mock.patch()
와 코드스멜
- 저자는
mock.patch()
를 코드스멜로 본다.
mock.patch()
로는 설계개선에 도움이 되지 않는다.
- 처음 구현에서
mock.patch()
를 잘쓰면 I/O 없이 테스트 코드를 작성할 수 있다.
- mock 테스트는 실제 구현 세부 사항과 밀접하게 연관된다. 이는 세부 구현 변경시 테스트를 깨지게 할 수 있다.
- mock 이 과용되면 테스트가 너무 복잡해져서 테스트 대상 코드 동작을 파악하기 어려워진다.