Spoiler alert: This describes Advent of Code Day 2 and Day 5 challenges with links to code solutions.

Lately, I've been playing around with Advent of Code 2019 during quarantine, trying to TDD my way through each challenge. Day 5 was particularly interesting because it builds off code written for Day 2 solutions. To keep things DRY, I decided to reuse my Day 2 code by bundling it into a single Python package. Since solutions for two days now point at the intcode and instruction modules, I'll be ready to use them if some future Advent of Code challenge requires them!

Trying something new: standard I/O substitution in Python unit tests

Part of the Day 5 challenge is to write functionality that handles interactive user input and functionality that prints some output to the screen. When I approached writing the tests for these components, I considered just writing some private function to pass keyboard inputs as a string parameter, along with some private output function that would return a value to test against (instead of printing to screen). And defer to some outer wrapper functions to handle keyboard inputs and printing to screen.

Then I got curious about how one might actually test functions that expect to use the standard I/O defaults of keyboard and text terminal. Having the tests substitute I/O defaults seemed like it could work, and I wouldn't have the overhead of untested wrapper functions, which have the potential to get stuffed with other functionality. I have never attempted to write tests that substitute standard I/O defaults before. This lead me down a rabbit hole, where I got to play with lambdas and contextmanager.

Using lambdas for input function substitution

Mark Rushakoff describes how to swap out one input function for another. In this StackOverflow answer and code example., he points out:

"Do you really need to unit test Python's raw_input method? The open method? os.environ.get? No."

I agree, so I implemented Rushakoff's example in my Day 5 code by enabling users to define an input function, which allows my tests to pass in simulated keyboard input. Sweet!

In common_modules/intcode.py:

class IntCode:
    def __init__(self, initial_state, user_input_function=input):
        self.user_input_function = user_input_function
     
     ...
     
     elif opcode == self.INPUT_OPCODE:
     print("Please provide input: ")
     user_input = self.user_input_function()

in 05/test/test_intcode.py

mock_user_input = "2"
intcode_program = IntCode(initial_state, user_input_function=lambda: mock_user_input)

Using contextmanager to capture output for testing

Next, I wanted to figure out how to write test assertions against printed output. Rob Kennedy posted this use of the contextmanager decorator to temporarily substitute stdout and stderr's defaults with StringIO() inside a with block. Once the block runs, the system output defaults are restored. This was exactly what I needed to make assertions with string comparisons within my day 05 tests.

from contextlib import contextmanager
from io import StringIO
import sys

class TestOutput:
    # Context manager for capturing output within a with block
    #  https://stackoverflow.com/a/17981937
    @contextmanager
    def captured_output():
        new_out, new_err = StringIO(), StringIO()
        old_out, old_err = sys.stdout, sys.stderr
        try:
            sys.stdout, sys.stderr = new_out, new_err
            yield sys.stdout, sys.stderr
        finally:
            sys.stdout, sys.stderr = old_out, old_err

    # Structure of a custom assertion class and method
    # https://stackoverflow.com/questions/6655724/how-to-write-a-custom-assertfoo-method-in-python
    def assertOutputIs(self, expected_output, with_block):
        with TestOutput.captured_output() as (out, err):
            with_block()

        output = out.getvalue().strip()
        actual_output = output.split("\n")[-1]

        self.assertEqual(expected_output, actual_output)

Keeping it DRY

Bundling everything up in a custom test assertion class enabled me to DRY up many with blocks from my Day 5 test code. Per Alan Cristhian's suggested code organization on StackOverflow, I was able to reuse the custom assertOutputIs assertion in several intCode tests, which DRYed things up those files considerably!

In 05/test/test_intcode.py:

from test_classes.test_output import TestOutput

class TestIntCode(unittest.TestCase, TestOutput):

    def test_position_mode_equal_true(self):
        mock_user_input = "8"
        expected_output = "1"
        initial_state = "3,9,8,9,10,9,4,9,99,-1,8"
        intcode_program = IntCode(initial_state, user_input_function=lambda: mock_user_input)

        self.assertOutputIs(expected_output, with_block=lambda: intcode_program.final_state)