My general strategy for testing is to separate out pure from non-pure functions.
All programs need a main function which should have the context needed to run the program as it's expected to run. This code can be tested using integration tests, but can't be unit tested. It is ideal to keep this function small, using external configuration if possible.
Pure functions don't need any tricks to test correctly. Just give them data, and they'll do their thing. This code should be where the business logic resides.
Non-pure functions are necessary for performing IO tasks or for doing something which varies (getting the current day of the week for instance). These functions can sometimes be tested with integration tests, other times not. The idea is to keep the code inside these functions extremely small so that it can be easily verified with a quick glance. These functions shouldn't contain branching logic, loops, or anything of the sort.
It is ideal to perform all of the needed IO with non-pure functions before giving the data to pure functions to do all of the calculation. Keeping the logic and IO separate allows the logic to be tested easily by just providing mock data.
This is not always possible to do though. There is often a need to perform IO inline with logic. This is in order to affect which IO tasks are to be performed, or whether it should be performed at all. The problem is, with unit-testing, you want to avoid IO. The way to solve this is by using dependency-injection and mocking or stubbing these interactions. This can be done with closures or objects.
Closures are a poor man's object. Objects are a poor man's closure.
import datetime
from typing import Callable
# Our main function which is hard/impossible to test. Try to keep it small.
def main() -> None:
weekday = get_weekday()
fs_writer = create_fs_writer(
"a_file.txt",
"This file was created on a Monday",
)
run_if_monday(weekday, fs_writer)
# A non-pure function which writes to the file system. Ideally, it would only
# do this.
def create_fs_writer(filename: str, text: str) -> Callable[[], None]:
def write_to_filesystem() -> None:
with open(filename, "w+") as f:
f.write(text)
return write_to_filesystem
# A non-pure function because the output will vary with the system date
def get_weekday() -> str:
return str(datetime.datetime.now().strftime("%A"))
# A higher order function, which runs an arbitrary function given a certain a
# condition
def run_if_monday(weekday: str, func: Callable[[], None]) -> None:
if weekday == "Monday":
func()
# Testing the logic
def test_run_if_monday() -> None:
def self_aware_function() -> None:
self_aware_function.has_been_called = True
self_aware_function.has_been_called = False
run_if_monday("Tuesday", self_aware_function)
assert not self_aware_function.has_been_called
run_if_monday("Monday", self_aware_function)
assert self_aware_function.has_been_called
if __name__ == "__main__":
main()
import datetime
from typing import Callable, Protocol
# Protocols create a type which is defined by its methods
class FileWriter(Protocol):
def write_file(self, filename: str, text: str) -> None:
...
# A FileWriter which actually writes a file
class OsWriter(object):
# can't really test this without writing to the actual file system
def write_file(self, filename: str, text: str) -> None:
with open(filename, "w+") as f:
f.write(text)
# A FileWriter which pretends to write a file
class MockWriter(object):
def __init__(self) -> None:
self.file_written = False
# flips a flag to say that this method has been run
def write_file(self, filename: str, text: str) -> None:
self.file_written = True
# Our main function which is hard/impossible to test. Try to keep it small.
def main() -> None:
weekday = get_weekday()
file_writer = OsWriter()
write_file_if_monday(weekday, file_writer)
# A non-pure function because the output will vary with the system date
def get_weekday() -> str:
return str(datetime.datetime.now().strftime("%A"))
# This function runs the write_file method of whatever is passed to it
def write_file_if_monday(weekday: str, file_writer: FileWriter) -> None:
if weekday == "Monday":
file_writer.write_file(
"a_file.txt",
"This file was created on a Monday",
)
# Testing the logic
def test_run_if_monday() -> None:
file_writer = MockWriter()
assert not file_writer.file_written
write_file_if_monday("Tuesday", file_writer)
assert not file_writer.file_written
write_file_if_monday("Monday", file_writer)
assert file_writer.file_written
if __name__ == "__main__":
main()