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