検索ガイド -Search Guide-

単語と単語を空白で区切ることで AND 検索になります。
例: python デコレータ ('python' と 'デコレータ' 両方を含む記事を検索します)
単語の前に '-' を付けることで NOT 検索になります。
例: python -デコレータ ('python' は含むが 'デコレータ' は含まない記事を検索します)
" (ダブルクオート) で語句を囲むことで 完全一致検索になります。
例: "python data" 実装 ('python data' と '実装' 両方を含む記事を検索します。'python data 実装' の検索とは異なります。)
当サイトのドメイン名は " getwebtips.net " です。
トップレベルドメインは .net であり、他の .com / .shop といったトップレベルドメインのサイトとは一切関係ありません。
architecture_patterns_with_python

Python で学ぶ architecture patterns - DDD (domain driven design) - coupling and abstractions (結合と抽象) の巻 投稿一覧へ戻る

Published 2022年5月12日16:42 by T.Tsuyoshi

SUPPORT UKRAINE

- Your indifference to the act of cruelty can thrive rogue nations like Russia -

Chapter 3. A Brief Interlude: On Coupling and Abstractions
(第3章. ちょっと脱線: 相互依存と抽象化)

ここで「抽象化 (abstractions)」について少し考えてみたいと思います。これまでも「抽象化」についてはかなりの紙面?を割いてきました。リポジトリパターンは永続/持続ストレージ (permanent storage) に対する抽象化でした。しかし、「良い」抽象化とはどのようなものなのでしょう?抽象化することで何が得られるのでしょう?テストを実行するときにどのように役立てることができるのでしょう?
TIP
この章のコードは GitHub の chapter_03_abstractions branch からどうぞ:
git clone https://github.com/cosmicpython/code.git
git checkout chapter_03_abstractions
取り上げる様々なパターンに埋もれがちではありますが、この本の重要なテーマは、厄介な詳細を単純な抽象化で隠す、ということです。趣味や kata (TDD の練習でよく用いられるちょっとしたプログラミングの課題 - 詳しくは Peter Provost の こちらのブログ をどうぞ) でコードを書いているのであれば、アイデアを自由に試すことも、苦心惨憺してこねくり回すことも、大胆にリファクタリングすることも可能ですが、大規模なシステムでは、システム内の様々な場所で下された決定に縛られることになります。
もしコンポーネント A に対する変更がコンポーネント B に影響を及ぼす可能性があるために実行できないとき、それらのコンポーネントは「結合/相互依存している (become coupled)」、と言います。ローカルで考えれば coupling は悪いことではありません: コード同士が連携して機能し、コンポーネント同士がサポートし合い、すべてがまるで時計の歯車のように所定の位置に収まっていることを表しているからです。専門用語では、相互依存している要素間に強い凝集力が働いている (there is high cohesion between the coupled elements)、と表現されます。
しかし大局的にみると、coupling は困りものです。コード変更のリスクとコストを高め、時には、全く変更が不可能である、と感じさせることさえあります。
これが Ball of Mud pattern といわれる問題です。機能追加等でアプリケーションが大きくなる段階で、ある機能を提供するための関連性 (凝集度: cohesion) が弱い要素同士の結合 (coupling: 相互依存性) を防止できない場合、効率的なシステム変更が最早不可能になるまで coupling は超直線的に増加し続けます。
システム内におけるこのような coupling の増加割合を抽象化によって減少させることが可能です:
Figure_3_1_Lots_of_coupling
Figure 3-1. Lots of coupling
Figure_3_2_Less_coupling
Figure 3-2. Less coupling
- Harry Percival, Bob Gregory (March 2020). Architecture Patterns with Python. O'Reilly Media, Inc -
どちらの図の構成においても2つのシステムが存在し、一方が他方に依存しています (矢印の数が結合の強さを示します)。図 3-1 の方が双方の結合度合が強く、この状況で System B に変更を加える必要が生じたとすると、その影響が System A に及ぶ可能性はかなり高くなります。
一方 図 3-2 では、2つのシステムの間に単純な抽象化層を新たに設けることで、お互いの結合度を減少させています。依存度の高い System A の方が単純と考えられますから、抽象化層への依存も弱くなります。この抽象化により、System B 内で実行されている複雑な操作が隠蔽され、そこで加えられる変更が System A に影響を及ぼすことを心配する必要がなくなります。つまり、左側の矢印を変更することなく右側の矢印に変更を加えることができるわけです。
Abstracting State Aids Testability
(状態の抽象化によるテスト性の向上)
例を見てみましょう。2つのファイルディレクトリを同期させるコードを記述したいとします。ここでは一方を source、もう一方を destination と呼ぶことにしましょう:
source にあって destination にないファイルはコピーする
source と destination に内容は同じだが名前が異なるファイルが存在する場合、destination のファイル名を source のファイル名にリネームする
destination にはあるが source にはないファイルは削除する
1番目と3番目の機能を実装するのは簡単です: paths が要素の2つのリストを比較するだけです。しかし2番目はそれほど単純ではありません。名前は異なるが内容が同じファイルを見つけるには、当然ですがファイルの中身を調べる必要があります。このためには、MD5 や SHA-1 といったハッシュ関数を利用することができます。ファイルから SHA-1 ハッシュ値を生成するコードは非常に単純です:
Hashing a file (sync.py)
import hashlib
import os
import shutil
from pathlib import Path

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 = fule.read(BLOCKSIZE)
return hasher.hexdigest()
続いて、何をすべきかを決定するためのコードを記述する必要があります - 言い換えればビジネスロジックです。
白紙の状態からある問題に取り組む必要がある場合、まずは簡単な実装を行い、それを基により良いデザインへリファクタリングしていくのが一般的です。これはコードを記述する際に現実世界でも取り入れられている方法ですから、この本でもこのアプローチに従います: 問題の中で最も単純な部分の解決策から記述を始め、対象部分を少し広げてデザインも向上させ、という工程を繰り返します。
最初の実装は次のようにしました:
Basic sync algorithm (sync.py)
def sync(source, dest):
# source フォルダ内を走査しファイル名とハッシュ値からなる dict を作成します
source_hashes = {}
for folder, _, files in os.walk(source):
for fn in files:
source_hashes[hash_file(Path(folder) / fn)] = fn

seen = set() # destination 内で見つけたファイルをセット

# destination フォルダ内を走査しファイル名とハッシュ値を取得
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)

# source 内に存在しないファイルであれば削除
if dest_hash not in source_hashes:
dest_path.unlink()

# 内容は同じだが名前が異なるファイルが存在する場合、source の「パス+ファイル名」に合わせる
elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

# source には存在するが destination には存在しないファイルは destination にコピーする
for src_hash, fn in source_hashes.items():
if src_hash not in seen:
shutil.copy(Path(source) / fn, Path(dest) / fn)
素晴らしい!いい感じのコードを記述し終えました。ただ、実際のハードドライブを対象に実行する前に、テストする必要がありますね。さて、こういったコードはどうテストしたらいいんでしょうか?
Some end-to-end tests (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
try:
source = tempfile.mkdtemp()
dest = tempfile.mkdtemp()

content = 'I am a very useful file'
(Path(source) / 'my-file').write_text(content)

sync(source, dest)

expected_path = Path(dest) / 'my-file'
assert expected_path.exists()
assert expected_path.read_text() == content

finally:
shutil.rmtree(source)
shutil.rmtree(dest)

def test_when_a_file_has_been_renamed_in_the_source():
try:
source = tempfile.mkdtemp()
dest = tempfile.mkdtemp()

content = 'I am a file that was renamed'
soruce_path = Path(source) / 'source-filename'
old_dest_path = Path(dest) / 'dest-filename'
expected_dest_path = Path(dest) / 'source-filename'
source_path.write_text(content)
old_dest_path.write_text(content)

sync(source, dest)

assert old_dest_path.exists() is False
assert expected_dest_path.read_text() == content

finally:
shutil.rmtree(source)
shutil.rmtree(dest)
こんな単純なテストを2つするだけのためにえらく多くの「前準備」が必要ですね。こうなってしまう根源は、我々の「2つのディレクトリ間の違いを見つける」というドメインロジックが I/O コードにべったりと依存してしまっているからです。我々の「2つのディレクトリ間の違いを見つける」アルゴリズムは、pathlib, shutil, hashlib といったモジュールなしには成り立ちません。
更に、現時点までの実装に比して十分なテストを実行できていない、という問題も生じています: 実はこの実装にはいくつかのバグが含まれています (例えば shutil.move() の動作が間違っています)。ここまでの実装部分を十分にカバーしこれらのバグをあぶり出すためにはより多くのテストを記述する必要があります。しかし、1つのテストを記述するのにこのように多くの「前準備」が必要なのであれば、数多くのテストを記述するのは「苦痛」以外の何物でもありません。
それに加えてこのコードは「拡張的である」とはとても言えません。例えば、--dry-run フラグを指定可能にしたい、としましょう。このフラグが指定された場合は、実際にハードディスクに対する操作を行うのではなく、どういった処理が行われるのか、ということを出力するだけにしたいのですが、すぐに追加できるでしょうか?また、リモートサーバーやクラウドストレージを処理対象にしたい場合はどうでしょう?
我々のビジネスロジック実装はファイル操作という「基盤操作 (low-level details)」に結合してしまっており、これが物事を厄介にしています。実装すべき機能がより複雑になればなるほどテストの記述は困難になるでしょう。これらのテストをリファクタリングすることはもちろん可能ですが (例えば、後処理を pytest の fixtures として記述する、等)、ファイル操作がついて回る限り、テスト自体の実行速度は遅く、ファイル操作周りのコードは煩雑なままでしょう。

Choosing the Right Abstraction(s)
(適切な抽象化手法の選択)

ビジネスロジック部分のコードをどのように書き換えればテストをより簡単に記述することができるでしょうか?
まず最初に、ファイルシステムに対してどのような操作が必要なのか、を考える必要があります。コードを追ってみると、操作を3種類に大別できることが分かります。我々はこれらのファイルシステム操作を、ビジネスロジック側コードが「解決すべき責務 (responsibilities)」と捉えましょう:
os.walk() を利用して対象ディレクトリ内を走査し、各ファイルについてハッシュ値を取得します。
各ファイルについて、新規追加すべきか、名前を変更すべきか、削除すべきかを決定します。
source と destination の内容が等しくなるように、copy / move / delete を行います。
これらそれぞれの「解決すべき責務」に対応する「簡単な抽象化 (simplifying abstractions)」方法を考えましょう。そうすることで煩雑な基盤操作部分が隠蔽され、「より想像力を発揮可能」なビジネスロジックに注力することができるようになります (インターフェース、という考え方に馴染みのある方もいるでしょう。ここで定義しようとしているのはまさに「それ」です)。
NOTE
この章では、いろいろな機能が押し込められている「こなれていない」コードを、よりテストを実行しやすい構造へとリファクタリングします。そのために、処理を機能ごとに分類し、それぞれの処理に特化した「受け持ち」を定義して委ねることにします。
ステップ 1 と 2 に関しては、実はすでにちょっとした抽象化を取り入れています。それは、ハッシュ値とパスの key: value ペアからなる dictionary の導入です。そして、中にはこう考えている方もいるのではないでしょうか: 「destination フォルダに関しても source フォルダと同様に dictionary を作っちゃえばいいのに。そうすれば2つの dictionaries を比較するだけじゃん」。ファイルシステムの現状を把握するには良い方法のように思えます:
source_files = {'hash1': 'path1', 'hash2': 'path2'}
dest_files = {'hash1': 'path1', 'hash2': 'pathX'}
ステップ 2 から 3 への流れについてはどうでしょう? copy / move / delete といったファイルシステムに対する実操作をいかに抽象化すればいいのでしょうか?
ここでは、この本でも後ほど大々的に採用するある種のトリックを採用したいと思います。まず「やりたいこと」と「やる方法」を分けてから、以下のような「コマンド」のタプルからなるリストを吐き出すプログラムを作成します:
('COPY', 'sourcepath', 'destpath'),
('MOVE', 'old', 'new')
こうすれば、入力としてファイルシステムの現状を模した2つの dicrionary を用意し、出力として期待される「コマンド」のタプルからなるリストと assert を行うテストを記述することができます。テスト時に用意する必要があるのはこの「入力」と「出力」の2つのデータだけです。
つまり、「実際のファイルシステム上にあるファイルはこれなんです。で、コードを実行したときに、どんな操作が実行されたのかチェックします」、というテストから、「例えばこういう "抽象化された" ファイルシステムがあるとします。この時実行されるべき "抽象化された" アクションはこうですよね?」というテストに変化する、ということです。
Simplified inputs and outputs in our tests (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {}
expected_actions = [('COPY', '/src/fn1', '/dst/fn1'),]
...

def test_when_a_file_has_been_renamed_in_the_source():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {'hash1': 'fn2'}
expected_actions = [('MOVE', '/dst/fn2', '/dst/fn1'),]
...

Implementing Our Chosen Abstractions
(考案した抽象化の実装)

以前記述したテストと比較して非常にいい感じです。But how do we actually write those new tests, そして、これらのテストを満たすために実装にどのような変更を加える必要があるでしょうか?
我々の目的は、ビジネスロジック部分を「独立」させることです。そして、その部分のテストに集中できるように、ファイルシステム関連の「前準備」に力を削がずに済ませることです。外部の状態には依存しない「コア」部分をコーディングし、その部分が外部からの入力に対してどう反応するか、を確かめます (こういったアプローチは、Gary Bernhardt によって Functional Core, Imperative Shell [FCIS: 機能するコア、欠かせない外枠] と分類されています)。
まず最初に、functional-core 部分と imperative-shell 部分でコードを分割しましょう。
我々のトップレベル関数はほぼほぼロジックを含みません; 入力を受け付け、ロジックを呼び出し、その指示に従って操作を行う、という不可欠な手順の羅列です:
Split our code into three (sync.py)
def sync(source, dest):
# step 1: imperative-shell; 入力の収集
source_hashes = read_paths_and_hashes(source) # ①
dest_hashes = read_paths_and_hashes(dest) # ①

# step2: functional-core; ロジックの呼び出し
actions = determine_actions(source_hashes, dest_hashes, source, dest) # ②

# step3: imperative-shell; 指示に沿った操作
for action, *paths in actions:
if action == 'copy':
shutil.copyfile(*paths)
if action == 'move':
shutil.move(*paths)
if action == 'delete':
os.remove(paths[0])
この read_paths_and_hashes() がコアから分離する最初の関数になります。I/O 部分の切り離しを行います。
functional-core の括り出しです。これこそがビジネスロジックになります。
ハッシュ値とファイル名の key: value ペアからなる dictionary を作成するためのコードはこんな感じです。source と dest で使いまわしができますね:
A function that just does I/O (sync.py)
def read_paths_and_hashes(root):
hashes = {}
for folder, _, files in os.walk(root):
for fn in files:
hashes[hash_file(Path(folder) / fn)] = fn
return hashes
determine_actions() 関数が我々のビジネスロジックのコアになります; 「ハッシュ値とファイル名のペアからなるこうした 2セットのデータがある場合は、copy/move/delete といった操作を行いなさい」という「業務指示」を出すわけです。入力形式は単純なデータ型 (dictionary)、出力形式も単純なデータ型 (tuple) です:
A function that just does business logic (sync.py)
def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
for sha, filename in src_hashes.items():
if sha not in dst_hashes:
sourcepath = Path(src_folder) / filename
destpath = Path(dst_folder) / filename
yield 'copy', sourcepath, destpath
elif dst_hashes[sha] != filename:
olddestpath = Path(dst_folder) / dst_hashes[sha]
newdestpath = Path(dst_folder) / filename
yield 'move', olddestpath, newdestpath

for sha, filename in dst_hashes.items():
if sha not in src_hashes:
yield 'delete', dst_folder / filename
テストでは determine_actions() 関数の返り値を assert するだけです:
Nicer-looking tests (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {}
actions = determine_actions(src_hashes, dst_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():
src_hashes = {'hash1': 'fn1'}
dst_hashes = {'hash1': 'fn2'}
actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
assert list(actions) == [('move', Path('/dst/fn2'), Path('/dst/fn1'))]
2つのディレクトリの差異を識別するコアロジックを低レベルの I/O データ/操作から切り離したため、機能 (function) に焦点を当ててテストを実行することが容易になりました。
このアプローチによって、この実装のエントリポイントとなっている sync() 関数に対するテストではなく、ロジックのコアファンクションである determine_actions() 関数に対するテストに移行しています。sync() 関数は当初の実装よりもかなり簡素になっていますから、このテスト内容で十分と判断する方もいれば、sync() 関数に対してもいくらかの統合テスト (integration tests) / 承認テスト (acceptance tests) が必要、と決断される方もいるでしょう。また、これら2つ以外にも、sync() 関数を変更することで、それ自体に対する単体テスト (unit tests) / エンドツーエンドテスト (end-to-end tests) が行えるようにする、という選択肢もあります。このアプローチは Bob が エッジツーエッジテスト (edge-to-edge tests) と呼んでいるものです。
Testing Edge to Edge with Fakes and Dependency Injection
(フェイクと依存性挿入によるエッジツーエッジテスト)
新たなシステムの実装を開始する際、まずはコアロジックに焦点を定め、単体テストを実施しながら開発を行っていくことはよくあることです。しかしある時点で、システムの他の部分も含めたより広範囲なテストを実施したい、と感じることもあります。
その場合、今まで通りのエンドツーエンドテスト (end-to-end tests) でも構いませんが、以前と同様記述するのもメンテナンスするのも困難であることに変わりはありません。その代わり、I/O 部分だけを「フェイク」して、システム全体を対象とするテストを記述してしまう手段を取ることも可能です:
Explicit dependencies (sync.py)
(依存の明示的実装)
def sync_di(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(sourcepath, destpath) # ③

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)
今回のトップレベル関数では、2つの依存関係を明示的に記述しています: reader と filesystem です。
reader には、対象ディレクトリのファイル情報を要素とする dictionary の作成、を依存します。
filesystem には、2つのディレクトリ間で同期をとるために適用する操作、を依存します。
TIP
ここでは依存性の注入 (dependency injection) を行っていますが、必ずしも抽象基底クラス (ABCs) やインターフェースを定義する必要はありません。この本ではここまで ABCs を「明示的」に記述してきましたが、それは、「抽象化」をよりよく理解していただくための助けとなることを期待してのものであり、絶対に必要、というものではありません。Python を利用する限り「動的型付けによるダックタイピング (duck typing)」 に頼ることができます。
Tests using DI (Dependency Injection)
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 test_when_a_file_exists_in_the_source_but_not_the_destination():
source = {'sha1': 'my-file'}
dest = {}
filesystem = FakeFileSystem()

reader = {'/source': source, '/dest': dest}
sync_di(reader.pop, filesystem, '/source', '/dest')

assert filesystem == [('COPY', '/source/my-file', '/dest/my-file')]

def test_when_a_file_has_been_renamed_in_the_source():
source = {'sha1': 'renamed-file'}
dest = {'sha1': 'original-file'}
filesystem = FakeFileSystem()

reader = {'/source': source, '/dest': dest}
sync_di(reader.pop, filesystem, '/source', '/dest')

assert filesystem == [('MOVE', '/dest/original-file', '/dest/renamed-file')]
Bob は、たとえ同僚の怒りを買ったとしても、list を継承した単純なテストダブル (test doubles: テスト対象が依存するコンポーネントの代用品) を用意することを好んでいます。これによって、assert foo not in database のようなテストを記述することが可能になります。
リストを継承しているテストダブルである FakeFileSystem クラスの各メソッドは、ただ単純に自分自身に「要素」を追加しているだけです。これによって、後でその「要素」を assert 文で比較することが可能になります。これは spy object の例です。
このアプローチの利点は、プロダクションコードが利用するのと全く関数を用いてテストを行えることです。半面、この方法の短所は、ステートフルなコンポーネントを明示的に作成しなければならず、そして、常にそれが付きまとうことです。Ruby on Rails の作成者である David Heinemeier Hansson がこのアプローチを「テスト至上がもたらすデザイン的欠陥 (test-induced design damage)」と評したのはよく知られています。
ただ、どちらのアプローチを採用するにしても、実装内に含まれる可能性がある全てのバグをあぶり出し修正することが可能になります: 全てのエッジケースに対するテストをはるかに容易に記述できます。
Why Not Just Patch It Out?
(パッチを当てればいいだけでは?)
ここまで進めてきて、「mock.patch を使えばこんな手間かけることないじゃん」と思われている方もいるかもしれません。
我々はこの本でも、関わっている実際のビジネスのコードでも mocks を使用しません。ここで mocks の是非について論争するつもりはありませんが、モックフレームワーク (mocking frameworks)、特にモンキーパッチ (monkeypatching) に関するものには 「コードの臭い (code smell: バグが潜んでいることを示唆する兆候)」 を禁じ得ないのです。
mocks に頼る代わりに、コードベース内における「役割/受け持ち」を明確にし、それぞれの役割をそれだけに特化した軽量なオブジェクトとして実装することで、容易にテストダブルと入れ替えることを可能にしたいのです。
NOTE
この本でも後の章で電子メール送信モジュールに対して mock.patch() を利用しますが、最終的には明示的な依存性注入に置き換えます。
上で述べた以外にも次のような理由に基づいての選択です:
依存している機能部分にパッチを適用することで対象コードの単体テストを実行することは可能ですが、それがデザインの向上に結び付くことはありません。mock.patch を利用しても --dry-run フラグを指定した際に機能するかを確認することはできませんし、FTP サーバーに対して実行することもできません。そのためには抽象化を導入する必要があります。
mocks を使用したテストは、コードベースの実装詳細との結合度合をより深める傾向があります。それは、モックテストが各パーツ間の関連性を確認するためのものだからです: 例えば、shutil.copy 関数を正しい引数で呼び出しただろうか、といったような。我々の経験では、こうしたコードとテストの密接さは、テスト自体の意義を薄めてしまうことにつながりかねません。
mocks の過剰利用はテストスーツの複雑さを招き、主役であるべき「コード」の存在を薄めてしまう可能性があります。
NOTE
テストのし易さを考慮した設計、というのはすなわち、拡張し易さを考慮した設計、にほかなりません。こうしたユースケースを実現するためのより洗練されたデザインを実装するために、簡潔性が若干犠牲になることがあるのはやむを得ません。
MOCKS VERSUS FAKES: CLASSIC-STYLE VERSUS LONDON-SCHOOL TDD
mocks と fakes の違いを端的に言い表すと次のようになるでしょう:
Mocks は「何がどのように使われるか」を検証します; ですから assert_called_once_with() [どんな引数を伴って呼び出されたか] といったようなメソッドが記述されます。これは London-school TDD といわれるアプローチで、システムの「振る舞い (behaviors)」に焦点を当てるものです。
Fakes はそれらが置き換える実装部分の機能を模倣しますが、テストでのみ使用されるように設計され、プロダクションベースでの使用は考慮されません。例として取り上げたメモリ内リポジトリがこれに当たります。システムの動作中の振る舞い (behaviors) というよりは最終結果の検証を行うために作成します。よって、classic-style TDD に分類されます。
ここの説明では、mocks を spies と、fakes を stubs と、それぞれ若干照らし合わせるような感じになっていますが、この話題についてより詳しく正確な「答え」を知りたい方は、Martin Fowler の古典的エッセイ"Mocks Aren't Stubs" をお読みください。
ここで例え、unittest.mock の MagicMock クラスは厳密にいえば mocks ではない、どちらかと言えば spies です、と言ったところで理解の助けにはならないでしょう。しかも、stubs や dummies として利用されることさえあります。詰まるところ、テストダブルにまつわる専門用語を取り上げて「ああでもない」「こうでもない」を論じているだけ、ということになってしまいますね。
London-school TDD と classic-style TDD についてはどうでしょう?この2つに関しても先程引用した Martin Fowler の記事で読むことができますし、こちらの Software Engineering Stack Exchange のサイト も参考にできます。しかしこの記事はかなり classic-style 寄りです。我々は、前準備 (setup) と検証 (assertion) の双方とも「状態 (state)」を設定するようにテストを記述しています。システムの振る舞い途中の周辺パーツとの様子をチェックするのではなく、可能な限り抽象化を推し進めた状態で作業を行いたいのです (こう書くと、London-school TDD は間違っている、と我々は判断しているのだな、と思われる方もいるかもしれませんが、そういうことではありません。そのように考えている「えらく」賢い人たちもいますが、私たちに関していえば、ただ単にそのやり方に慣れていない、だけです)。
TDD は、まず最初にシステムの設計をするためのもの、続けて、実際にテストを実行するためのもの、と見なされます。テストは、設計段階における我々の「選択」の記録として機能し、長期間の「ご無沙汰」後に再度コードに目を通した際の「システム解説書」としても役に立ちます。
あまりに多くの mocks を利用しセットアップコードで溢れかえっているテストは、こうした真に必要としている情報を覆い隠してしまいます。
Steve Freeman は "Test-Driven Development" の中で、mocks を過剰に利用したテストの良い例を提示しています。また、我々が尊敬するテック評論家である Ed Jung の PyCon での講演 "Mocking and Patching Pitfalls" も要チェックです。彼はこの中で、mocks を利用したテストとその代替案にも触れています。最後に、この章で取り上げてきている問題を、異なる単純な例を挙げて非常にうまく説明している Brandon Rhodes の "Hoisting Your I/O" もご覧ください。
TIP
この章では、エンドツーエンドテストを単体テストに置き換える、というテーマで多くの時間を費やしてきました。しかしこれは、エンドツーエンドテストを絶対に行うべきではない、と我々が考えている、ということではありません。可能な限り多くの単体テストを消化しつつ、エンドツーエンドテストは確証が持てる範囲内の最小限に抑える、という適切な「テストピラミッド」を構築するためのテクニックを示したい、と考えているだけです。
SO WHICH DO WE USE IN THIS BOOK? FUNCTIONAL OR OBJECT-ORIENTED COMPOSITION?
(この本では関数型、オブジェクト指向コンポジションのどちらを使うの?)
両方とも使用します。我々の domain model は依存性 / side effects の面で完全に独立しており、これが機能面の中核となります。そして、その周囲にサービスレイヤーを構築することで、エッジツーエッジにシステムを動作させることが可能になります。逆に言えば、依存性注入を利用してある特定の状態を保持したこれらのサービスを「偽造」することで、これら自体のテストも実行できる、というわけです。
依存性注入 (dependency injection) の理解がより深まるように、この話題を中心取り上げる章を後ほど設ける予定です。

Wrap-Up
(まとめ)

この本ではここで紹介したアイデアが何回も登場します: ビジネスロジックと煩雑な基盤操作 (I/O 操作) 間の結合を弱めることで、システムのテスト性とメンテナンス性を向上させます。適切な抽象化方法を見出すのは困難を伴いますが、その際に自問すべき項目をいくつか挙げてみます:
お馴染みの Python のデータ構造でシステムの煩雑な状態を表現することが可能だろうか?そして、その表現した状態を返す1つの関数を思い描けるだろうか?
システムをどのように「切り分ける」ことができるだろうか?どこに抽象化を差し挟むことが可能だろうか?
システムを、異なる役割毎にそれを受け持つコンポーネントに分ける際の賢明な方法はどのようなものだろうか?明示的に表現できる暗黙的なコンセプトはどれだろうか?
何が依存であり、何がビジネスにおけるコアロジックだろうか?
このような話を頭で考えているだけでは逆に謎が深まります。不完全さを解消するには練習が一番です。さて、脱線はここまでにして通常のプログラミンに戻りましょう...

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)