Skip to content

Commit 7dc9c50

Browse files
committed
improve DI example in abstractions chapter
1 parent f0e43d8 commit 7dc9c50

File tree

1 file changed

+125
-70
lines changed

1 file changed

+125
-70
lines changed

chapter_03_abstractions.asciidoc

Lines changed: 125 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ def sync(source, dest):
196196
197197
# for every file that appears in source but not target, copy the file to
198198
# the target
199-
for src_hash, fn in source_hashes.items():
200-
if src_hash not in seen:
199+
for source_hash, fn in source_hashes.items():
200+
if source_hash not in seen:
201201
shutil.copy(Path(source) / fn, Path(dest) / fn)
202202
----
203203
====
@@ -353,14 +353,14 @@ what _abstraction_ of filesystem actions will happen?"
353353
[role="skip"]
354354
----
355355
def test_when_a_file_exists_in_the_source_but_not_the_destination():
356-
src_hashes = {'hash1': 'fn1'}
357-
dst_hashes = {}
356+
source_hashes = {'hash1': 'fn1'}
357+
dest_hashes = {}
358358
expected_actions = [('COPY', '/src/fn1', '/dst/fn1')]
359359
...
360360
361361
def test_when_a_file_has_been_renamed_in_the_source():
362-
src_hashes = {'hash1': 'fn1'}
363-
dst_hashes = {'hash1': 'fn2'}
362+
source_hashes = {'hash1': 'fn1'}
363+
dest_hashes = {'hash1': 'fn2'}
364364
expected_actions == [('MOVE', '/dst/fn2', '/dst/fn1')]
365365
...
366366
----
@@ -411,11 +411,11 @@ def sync(source, dest):
411411
412412
# imperative shell step 3, apply outputs
413413
for action, *paths in actions:
414-
if action == "copy":
414+
if action == "COPY":
415415
shutil.copyfile(*paths)
416-
if action == "move":
416+
if action == "MOVE":
417417
shutil.move(*paths)
418-
if action == "delete":
418+
if action == "DELETE":
419419
os.remove(paths[0])
420420
----
421421
====
@@ -453,21 +453,21 @@ structures:
453453
====
454454
[source,python]
455455
----
456-
def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
457-
for sha, filename in src_hashes.items():
458-
if sha not in dst_hashes:
459-
sourcepath = Path(src_folder) / filename
460-
destpath = Path(dst_folder) / filename
461-
yield "copy", sourcepath, destpath
462-
463-
elif dst_hashes[sha] != filename:
464-
olddestpath = Path(dst_folder) / dst_hashes[sha]
465-
newdestpath = Path(dst_folder) / filename
466-
yield "move", olddestpath, newdestpath
467-
468-
for sha, filename in dst_hashes.items():
469-
if sha not in src_hashes:
470-
yield "delete", dst_folder / filename
456+
def determine_actions(source_hashes, dest_hashes, source_folder, dest_folder):
457+
for sha, filename in source_hashes.items():
458+
if sha not in dest_hashes:
459+
sourcepath = Path(source_folder) / filename
460+
destpath = Path(dest_folder) / filename
461+
yield "COPY", sourcepath, destpath
462+
463+
elif dest_hashes[sha] != filename:
464+
olddestpath = Path(dest_folder) / dest_hashes[sha]
465+
newdestpath = Path(dest_folder) / filename
466+
yield "MOVE", olddestpath, newdestpath
467+
468+
for sha, filename in dest_hashes.items():
469+
if sha not in source_hashes:
470+
yield "DELETE", dest_folder / filename
471471
----
472472
====
473473

@@ -480,16 +480,16 @@ Our tests now act directly on the `determine_actions()` function:
480480
[source,python]
481481
----
482482
def test_when_a_file_exists_in_the_source_but_not_the_destination():
483-
src_hashes = {"hash1": "fn1"}
484-
dst_hashes = {}
485-
actions = determine_actions(src_hashes, dst_hashes, Path("/src"), Path("/dst"))
483+
source_hashes = {"hash1": "fn1"}
484+
dest_hashes = {}
485+
actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
486486
assert list(actions) == [("copy", Path("/src/fn1"), Path("/dst/fn1"))]
487487
488488
489489
def test_when_a_file_has_been_renamed_in_the_source():
490-
src_hashes = {"hash1": "fn1"}
491-
dst_hashes = {"hash1": "fn2"}
492-
actions = determine_actions(src_hashes, dst_hashes, Path("/src"), Path("/dst"))
490+
source_hashes = {"hash1": "fn1"}
491+
dest_hashes = {"hash1": "fn2"}
492+
actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
493493
assert list(actions) == [("move", Path("/dst/fn2"), Path("/dst/fn1"))]
494494
----
495495
====
@@ -529,34 +529,33 @@ system together but fake the I/O, sort of _edge to edge_:
529529
[source,python]
530530
[role="skip"]
531531
----
532-
def sync(reader, filesystem, source_root, dest_root): #<1>
532+
def sync(source, dest, filesystem=FileSystem()): #<1>
533+
source_hashes = filesystem.read(source) #<2>
534+
dest_hashes = filesystem.read(dest) #<2>
533535
534-
source_hashes = reader(source_root) #<2>
535-
dest_hashes = reader(dest_root)
536-
537-
for sha, filename in src_hashes.items():
536+
for sha, filename in source_hashes.items():
538537
if sha not in dest_hashes:
539-
sourcepath = source_root / filename
540-
destpath = dest_root / filename
541-
filesystem.copy(destpath, sourcepath) #<3>
538+
sourcepath = Path(source) / filename
539+
destpath = Path(dest) / filename
540+
filesystem.copy(sourcepath, destpath) #<3>
542541
543542
elif dest_hashes[sha] != filename:
544-
olddestpath = dest_root / dest_hashes[sha]
545-
newdestpath = dest_root / filename
546-
filesystem.move(olddestpath, newdestpath)
543+
olddestpath = Path(dest) / dest_hashes[sha]
544+
newdestpath = Path(dest) / filename
545+
filesystem.move(olddestpath, newdestpath) #<3>
547546
548-
for sha, filename in dst_hashes.items():
547+
for sha, filename in dest_hashes.items():
549548
if sha not in source_hashes:
550-
filesystem.delete(dest_root/filename)
549+
filesystem.delete(dest / filename) #<3>
551550
----
552551
====
553552

554-
<1> Our top-level function now exposes two new dependencies, a `reader` and a
555-
`filesystem`.
553+
<1> Our top-level function now exposes a new dependency, a `FileSystem`.
556554

557-
<2> We invoke the `reader` to produce our files dict.
555+
<2> We invoke `filesystem.read()` to produce our files dict.
558556

559-
<3> We invoke the `filesystem` to apply the changes we detect.
557+
<3> We invoke the ++FileSystem++'s `.copy()`, `.move()` and `.delete()` methods
558+
to apply the changes we detect.
560559

561560
TIP: Although we're using dependency injection, there is no need
562561
to define an abstract base class or any kind of explicit interface. In this
@@ -566,24 +565,81 @@ TIP: Although we're using dependency injection, there is no need
566565

567566
// IDEA [KP] Again, one could mention PEP544 protocols here. For some reason, I like them.
568567

569-
[[bob_tests]]
568+
The real (default) implementation of our FileSystem abstraction does real I/O:
569+
570+
[[real_filesystem_wrapper]]
571+
.The real dependency (sync.py)
572+
====
573+
[source,python]
574+
[role="skip"]
575+
----
576+
class FileSystem:
577+
578+
def read(self, path):
579+
return read_paths_and_hashes(path)
580+
581+
def copy(self, source, dest):
582+
shutil.copyfile(source, dest)
583+
584+
def move(self, source, dest):
585+
shutil.move(source, dest)
586+
587+
def delete(self, dest):
588+
os.remove(dest)
589+
----
590+
====
591+
592+
But the fake one is a wrapper around our chosen abstractions,
593+
rather than doing real I/O:
594+
595+
[[fake_filesystem]]
570596
.Tests using DI
571597
====
572598
[source,python]
573599
[role="skip"]
574600
----
575-
class FakeFileSystem(list): #<1>
601+
class FakeFilesystem:
602+
def __init__(self, path_hashes): #<1>
603+
self.path_hashes = path_hashes
604+
self.actions = [] #<2>
605+
606+
def read(self, path):
607+
return self.path_hashes[path] #<1>
576608
577-
def copy(self, src, dest): #<2>
578-
self.append(('COPY', src, dest))
609+
def copy(self, source, dest):
610+
self.actions.append(('COPY', source, dest)) #<2>
579611
580-
def move(self, src, dest):
581-
self.append(('MOVE', src, dest))
612+
def move(self, source, dest):
613+
self.actions.append(('MOVE', source, dest)) #<2>
582614
583615
def delete(self, dest):
584-
self.append(('DELETE', dest))
616+
self.actions.append(('DELETE', dest)) #<2>
617+
----
618+
====
619+
620+
<1> We initialize our fake filesysem using the abstraction we chose to
621+
represent filesystem state: dictionaries of hashes to paths.
622+
623+
<2> The action methods in our `FakeFileSystem` just appends a record to an list
624+
of `.actions` so we can inspect it later. This means our test double is both
625+
a "fake" and a "spy".
626+
((("test doubles")))
627+
((("fake objects")))
628+
((("spy objects")))
629+
630+
So now our tests can act on the real, top-level `sync()` entrypoint,
631+
but they do so using the `FakeFilesystem()`. In terms of their
632+
setup and assertions, they end up looking quite similar to the ones
633+
we wrote when testing directly against the functional core `determine_actions()`
634+
function:
585635

586636

637+
[[bob_tests]]
638+
.Tests using DI
639+
====
640+
[source,python]
641+
[role="skip"]
642+
----
587643
def test_when_a_file_exists_in_the_source_but_not_the_destination():
588644
source = {"sha1": "my-file" }
589645
dest = {}
@@ -607,15 +663,6 @@ def test_when_a_file_has_been_renamed_in_the_source():
607663
----
608664
====
609665

610-
<1> Bob _loves_ using lists to build simple test doubles, even though his
611-
coworkers get mad. It means we can write tests like
612-
++assert 'foo' not in database++.
613-
((("test doubles", "using lists to build")))
614-
615-
<2> Each method in our `FakeFileSystem` just appends something to the list so we
616-
can inspect it later. This is an example of a spy object.
617-
((("spy objects")))
618-
619666

620667
The advantage of this approach is that our tests act on the exact same function
621668
that's used by our production code. The disadvantage is that we have to make
@@ -743,9 +790,12 @@ Steve Freeman has a great example of overmocked tests in his talk
743790
https://oreil.ly/jAmtr["Test-Driven Development"].
744791
You should also check out this PyCon talk, https://oreil.ly/s3e05["Mocking and Patching Pitfalls"],
745792
by our esteemed tech reviewer, Ed Jung, which also addresses mocking and its
746-
alternatives. And while we're recommending talks, don't miss Brandon Rhodes talking about
747-
https://oreil.ly/oiXJM["Hoisting Your I/O"],
748-
which really nicely covers the issues we're talking about, using another simple example.
793+
alternatives.
794+
795+
And while we're recommending talks, check out the wonderful Brandon Rhodes
796+
in https://oreil.ly/oiXJM["Hoisting Your I/O"]. It's not actually about mocks,
797+
but is instead about the general issue of decoupling business logic from I/O,
798+
in which he uses a wonderfully simple illustrative example.
749799
((("hoisting I/O")))
750800
((("Rhodes, Brandon")))
751801

@@ -792,12 +842,17 @@ a few heuristics and questions to ask yourself:
792842
messy system and then try to imagine a single function that can return that
793843
state?
794844

795-
* Where can I draw a line between my systems, where can I carve out a
796-
https://oreil.ly/zNUGG[seam] to stick that abstraction in?
845+
* Separate the _what_ from the _how_:
846+
can I use a data structure or DSL to represent the external effects I want to happen,
847+
independently of _how_ I plan to make them happen?
848+
849+
* Where can I draw a line between my systems,
850+
where can I carve out a https://oreil.ly/zNUGG[seam]
851+
to stick that abstraction in?
797852
((("seams")))
798853

799-
* What is a sensible way of dividing things into components with different
800-
responsibilities? What implicit concepts can I make explicit?
854+
* What is a sensible way of dividing things into components with different responsibilities?
855+
What implicit concepts can I make explicit?
801856

802857
* What are the dependencies, and what is the core business logic?
803858

0 commit comments

Comments
 (0)