Functional core, imperative shell
Code that decides and code that does are easier to test apart. Put the decisions in pure functions that take values and return values: the functional core. Put reading, writing and printing in a thin layer around it: the imperative shell.
from pathlib import Path
def word_counts(text):
counts = {}
for w in text.lower().split():
counts[w] = counts.get(w, 0) + 1
return counts
def main(path):
text = Path(path).read_text()
for w, n in word_counts(text).items():
print(w, n)
text = "to be or not to be"
Path("a.txt").write_text(text)
main("a.txt")word_counts needs no files and no mocks: a test passes a string and compares a dict. The shell has almost no logic, so one end-to-end test covers it.
Plans instead of actions
When the core has to cause effects, let it return a description of them, such as a list of ("copy", name) tuples, and let the shell carry them out. The decision becomes data you can assert on.
Your task
Write a pure plan_sync(source, target), where both map file names to contents, returning ("copy", name) for names only in source, ("update", name) where contents differ and ("delete", name) for names only in target, sorted by name. Write the shell sync(src_dir, dst_dir) that reads the files of both folders, carries out the plan in dst_dir and returns it.
The explanation opens here once every check passes.