Skip to content

Instantly share code, notes, and snippets.

@andygarfield
Last active March 30, 2021 15:41
Show Gist options
  • Save andygarfield/bbbc60d1a5b4caf1776a304c456531a0 to your computer and use it in GitHub Desktop.
Save andygarfield/bbbc60d1a5b4caf1776a304c456531a0 to your computer and use it in GitHub Desktop.

Testing

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.

The functional path

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()

The object-oriented path

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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment