cat
cat

Reputation: 4020

Unit testing keypresses and terminal output in Python?

If you search Google or SO for 'unit test stdin stdout python' you will find very many questions, each and every one of which is answered in one way or another with

Do you really need to unit test Python's builtin input / sys.stdin methods?

My answer is yes, I emphatically do, because I'm essentially implementing my own input + poor-man's libreadline / libcurses, and I need to test using stdin and the contents of the terminal.

I happen to use a Unix-derived OS so I have pipes | and shell redirection <, so I could write a little shell script to do this alongside some Python helper code, and to test the terminal's actions (ANSI escape sequences, cursor movement, exactly what gets printed, etc) I could read from a known /dev/tty/whatever, but there are two main reasons I don't want to do this:

  1. Testing code should be as cross-platform as the code it's testing (and not so fragile!)

  2. I quite like unittest, thank you, and I don't want to resort to shell scripting and unix hackery (as much as I like unix hackery) just to test my module.

There must be a better way to test things like curses, not when you're using curses but when you're developing a curses.


Since it was requested, here's some examples of what I'm looking to test: (full code on github)

def _Getch():
    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(sys.stdin.fileno())
        ch = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        return ch

class xyctl:
    def _terminal_size():
        import fcntl, struct
        h, w, hp, wp = struct.unpack('HHHH',
            fcntl.ioctl(0, termios.TIOCGWINSZ,
            struct.pack('HHHH', 0, 0, 0, 0)))
        return w, h, w * h

    def _matrix_calc(adj_x, adj_y):
        cur_x, cur_y = xyctl.getter()
        new_x, new_y = (
            (cur_x + adj_x),
            (cur_y + adj_y)
        )

        if (new_x * new_y) < (xyctl._terminal_size()[2]):
            return new_x, new_y
        else:
            _writer(CHAR_BEL)

    def getter():
        _writer(CHAR_ESC + "[6n")
        pos = until("R", raw=True)
        _writer(CHAR_CRR + CHAR_SPC * (len(pos) + 1) + CHAR_CRR)
        pos = pos[2:].split(";")
        pos[0], pos[1] = int(pos[1]), int(pos[0])
        return pos

    def setter(new_x, new_y):
        _writer(CHAR_ESC + "[{};{}H".format(new_x, new_y))

    def adjust(adj_x, adj_y):
        new_x, new_y = xyctl._matrix_calc(adj_x, adj_y)
        xyctl.setter(new_x, new_y)

Upvotes: 2

Views: 592

Answers (2)

hBy2Py
hBy2Py

Reputation: 1832

Late to the party here, but: I'm in exactly this situation, wanting to test the actual user-facing portions of the CLI, to be able to (i) ensure consistent behavior resulting from a given set of user keypresses, and (ii) inspect the actual content printed to console.

I put together a couple of classes that achieve this in (what seems to me) a pretty nice way, originally implemented here and, as of this writing, now published in v1.0 pre-release on PyPI as stdio-mgr. Most of it isn't especially novel, just a context manager to mock stdout/stderr/stdin over to temporary streams, as already described in answers like this one, this one and this one.

The big difference from anything I've seen elsewhere is that stdin is mocked to a custom StringIO subclass which (a) automatically tees everything read from the stream over to the mocked stdout, and thus echoes the "typed input" to the "console"; and (b) implements an .append() helper method to allow addition of more content to the end of mock-stdin without changing the seek position.

Example usage:

>>> from stdio_mgr import stdio_mgr
>>> with stdio_mgr() as (in_, out_, err_):
...     print('foobar')               # 'print' appends '\n'
...     in_.append('bazquux\n')       # Don't forget trailing '\n'!
...     inp_result = input('?')       # 'bazquux\n' is teed to 'out_' here
...     out_result = out_.getvalue()  # Pull the whole 'out_' stream contents
>>> inp_result
'bazquux'

>>> out_result
'foobar\n?bazquux\n'

Note that even though the string appended to in_ was newline-terminated (otherwise execution would hang), per the standard behavior input stripped that trailing '\n' before the string was stored in inp_result. As can be seen, the question mark used as the prompt for input is also stored in out_.

However, since TeeStdin tees the content read from "stdin" before it's passed to input, out_ received ?bazquux\n, not ?bazquux. While this newline handling has the potential to get somewhat confounded, I'm pretty sure there's not much to be done about it—any twiddles would probably break the transparency of the mock from the perspective of the wrapped code.

Upvotes: 1

akraf
akraf

Reputation: 3235

You can use the subprocess module to call your script from within python. You can then send test input via communicate

import subprocess as sp
import sys

interpreter_path = sys.executable
p = sp.Popen([interpreter_path, script_to_test])
(stdout, stderr) = p.communicate(input = testinput)

stdout and stderr can then be tested for correct values

Upvotes: 2

Related Questions