@@ -196,8 +196,8 @@ def sync(source, dest):
196
196
197
197
# for every file that appears in source but not target, copy the file to
198
198
# 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:
201
201
shutil.copy(Path(source) / fn, Path(dest) / fn)
202
202
----
203
203
====
@@ -353,14 +353,14 @@ what _abstraction_ of filesystem actions will happen?"
353
353
[role="skip"]
354
354
----
355
355
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 = {}
358
358
expected_actions = [('COPY', '/src/fn1', '/dst/fn1')]
359
359
...
360
360
361
361
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'}
364
364
expected_actions == [('MOVE', '/dst/fn2', '/dst/fn1')]
365
365
...
366
366
----
@@ -411,11 +411,11 @@ def sync(source, dest):
411
411
412
412
# imperative shell step 3, apply outputs
413
413
for action, *paths in actions:
414
- if action == "copy ":
414
+ if action == "COPY ":
415
415
shutil.copyfile(*paths)
416
- if action == "move ":
416
+ if action == "MOVE ":
417
417
shutil.move(*paths)
418
- if action == "delete ":
418
+ if action == "DELETE ":
419
419
os.remove(paths[0])
420
420
----
421
421
====
@@ -453,21 +453,21 @@ structures:
453
453
====
454
454
[source,python]
455
455
----
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
471
471
----
472
472
====
473
473
@@ -480,16 +480,16 @@ Our tests now act directly on the `determine_actions()` function:
480
480
[source,python]
481
481
----
482
482
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"))
486
486
assert list(actions) == [("copy", Path("/src/fn1"), Path("/dst/fn1"))]
487
487
488
488
489
489
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"))
493
493
assert list(actions) == [("move", Path("/dst/fn2"), Path("/dst/fn1"))]
494
494
----
495
495
====
@@ -529,34 +529,33 @@ system together but fake the I/O, sort of _edge to edge_:
529
529
[source,python]
530
530
[role="skip"]
531
531
----
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>
533
535
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():
538
537
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>
542
541
543
542
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>
547
546
548
- for sha, filename in dst_hashes .items():
547
+ for sha, filename in dest_hashes .items():
549
548
if sha not in source_hashes:
550
- filesystem.delete(dest_root/ filename)
549
+ filesystem.delete(dest / filename) #<3>
551
550
----
552
551
====
553
552
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`.
556
554
557
- <2> We invoke the `reader ` to produce our files dict.
555
+ <2> We invoke `filesystem.read() ` to produce our files dict.
558
556
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.
560
559
561
560
TIP: Although we're using dependency injection, there is no need
562
561
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
566
565
567
566
// IDEA [KP] Again, one could mention PEP544 protocols here. For some reason, I like them.
568
567
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]]
570
596
.Tests using DI
571
597
====
572
598
[source,python]
573
599
[role="skip"]
574
600
----
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>
576
608
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>
579
611
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>
582
614
583
615
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:
585
635
586
636
637
+ [[bob_tests]]
638
+ .Tests using DI
639
+ ====
640
+ [source,python]
641
+ [role="skip"]
642
+ ----
587
643
def test_when_a_file_exists_in_the_source_but_not_the_destination():
588
644
source = {"sha1": "my-file" }
589
645
dest = {}
@@ -607,15 +663,6 @@ def test_when_a_file_has_been_renamed_in_the_source():
607
663
----
608
664
====
609
665
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
-
619
666
620
667
The advantage of this approach is that our tests act on the exact same function
621
668
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
743
790
https://oreil.ly/jAmtr["Test-Driven Development"].
744
791
You should also check out this PyCon talk, https://oreil.ly/s3e05["Mocking and Patching Pitfalls"],
745
792
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.
749
799
((("hoisting I/O")))
750
800
((("Rhodes, Brandon")))
751
801
@@ -792,12 +842,17 @@ a few heuristics and questions to ask yourself:
792
842
messy system and then try to imagine a single function that can return that
793
843
state?
794
844
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?
797
852
((("seams")))
798
853
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?
801
856
802
857
* What are the dependencies, and what is the core business logic?
803
858
0 commit comments