Skip to content

Latest commit

 

History

History
195 lines (132 loc) · 7.61 KB

monitoring_the_output.rst

File metadata and controls

195 lines (132 loc) · 7.61 KB

Monitoring The Output

Next we want to test the following behaviour: we register a callback with our LineMonitor object using its .register_callback() method, and it calls our callback with each line of output it reads from the |pseudoterminal|.

Python streams have a useful .readline() method, so let's wrap the read file-descriptor of the |pseudoterminal| with a stream. It turns out that you can wrap a file descriptor with a simple call to the built-on open() function, so we'll use that.

Note that we add a new test, leaving the previous one intact. This means that we keep everything we already have working, while we add a test for this new behaviour.

Let's start by describing a scenario where we read several lines from the |pseudoterminal| and demand that they are transferred to our callback.

.. literalinclude:: ../../line_monitor/tests/unit/3/test_line_monitor.py
   :linenos:
   :lines: 18-32
   :emphasize-lines: 5,8-11,13-15

What's going on here?

  1. We add a demand that our code create a Python stream from the |pseudoterminal|'s read-descriptor before launching the subprocess.
  2. We then call .launch_subprocess() to meet those demands.
  3. We describe the "read-from-|pseudoterminal|-forwared-to-callback" data flow for 3 consecutive lines.
  4. We register a Fake('my_callback') object as our callback - this way, when the code calls the callback, it will be meeting our demands in this test. It's important that 'my_callback' is used as this Fake's name, since we refer to it in the Scenario.
  5. We then call the .monitor() method - this method should do all the reading and forwarding.

We must also remember to mock the built-in open:

.. literalinclude:: ../../line_monitor/tests/unit/3/test_line_monitor.py
   :linenos:
   :lines: 5-9
   :emphasize-lines: 5

We can already see a problem: the scenario is actually built out of two parts - the part which tests .launch_subprocess(), and the part which tests .monitor().

Furthermore, since we have our previous test in test_lauch_subprocess_with_pseudoterminal, which doesn't expect the call to open(), the two tests are in contradiction.

The way to handle this is to refactor our test a bit:

.. literalinclude:: ../../line_monitor/tests/unit/4/test_line_monitor.py
   :linenos:
   :emphasize-lines: 9,11-14,19,25


By convention, helper functions that help us modify scenarios end with _scenario.

OK this seems reasonable, let's get some |RED|! Running this both our tests fail:

E       Failed:
E       testix: ExpectationException
E       testix details:
E       === Scenario (no title) ===
E        expected: open('read_from_fd', encoding = 'latin-1')
E        actual  : subprocess.Popen(['my', 'command', 'line'], stdout = 'write_to_fd', close_fds = True)

We changed our expectations from .launch_subprocess() to call open(), but we did not change the implementation yet, so |testix| is surprised to find that we actually call subprocess.Popen - and makes our test fail.

Good, let's fix it and get to |GREEN|. We introduce the following to our code:

.. literalinclude:: ../../line_monitor/source/5/line_monitor.py
   :linenos:
   :emphasize-lines: 5-6,9,15,19-21

This passes the test, but that's not really what we meant - right? Obviously we would like a while True to replace the for _ in range(3) here.

However, if we write a while True, then |testix| will fail us for the 4th call to .readline(), since it only expects 3 calls.

Testing infinite, while True loops is a problem, but we can get around it by injecting an exception that will terminate the loop. Just as we can determine what calls to Fake objects return, we can make them raise exceptions.

|testix| even comes with an exception class just for this use case, TestixLoopBreaker`.` Let's introduce another ``.readline() expectation into our test, using |testix|'s Throwing construct:

.. literalinclude:: ../../line_monitor/tests/unit/6/test_line_monitor.py
   :linenos:
   :lines: 22-38
   :emphasize-lines: 13,16-17

NOTE - we once more change the test first. Also note that we can use Throwing to raise any type of exception we want, not just TestixLoopBreaker.

This gets us back into the |RED|.

E           Failed: DID NOT RAISE <class 'testix.TestixLoopBreaker'>

Since our code calls .readline() 3 times exactly, the fourth call, which would have resulted in TestixLoopBreaker being raised, did not happen.

Let's fix our code:

.. literalinclude:: ../../line_monitor/source/7/line_monitor.py
   :linenos:
   :lines: 18-21
   :emphasize-lines: 2

And we're back in |GREEN|.

Edge Case Test: When There is no Callback

What happens if .monitor() is called, but no callback has been registered? We can of course implement all kinds of behaviour, for example, we can make it "illegal", and raise an Exception from .monitor() in such a case.

However, let's do something else. Let's just define things such that output collected from the subprocess when no callback has been registered is discarded.

.. literalinclude:: ../../line_monitor/tests/unit/8/test_line_monitor.py
   :linenos:
   :lines: 40-52


Notice there's no .register_callback() here. We demand that .readline() be called, but we don't demand anything else.

Running this fails with a |RED|

    def monitor(self):
        while True:
            line = self._reader.readline()
>           self._callback(line)
E           TypeError: 'NoneType' object is not callable

Which reveals that we in fact, did not handle this edge case very well.

Let's add code that fixes this.

.. literalinclude:: ../../line_monitor/source/9/line_monitor.py
   :linenos:
   :lines: 18-23
   :emphasize-lines: 4-5

Our test passes - back to |GREEN|.

Edge Case Test: Asynchronous Callback Registration

What happens if we start monitoring without a callback, wait a while, and only then register a callback?

This allows a use case where we call the .monitor() (which blocks) in one thread, and register a callback in another thread.

Let's decide that in this case, the callback will receive output which is captured only after the callback has been registered.

Our test will be similar to test_receive_output_lines_via_callback(), however, we need to somehow make tested.register_callback() happen somewhere between one .readline() and the next. This is not so easy to do because of the same while True that gave us some trouble before.

|testix| allows us to simulate asynchronous events like this using its Hook construct. Essentially Hook(function, *args, **kawrgs) can be injected into the middle of a Scenario, and it will call function(*args, **kwargs) at the point in which it's injected.

Here's how to write such a test:

.. literalinclude:: ../../line_monitor/tests/unit/10/test_line_monitor.py
   :linenos:
   :lines: 54-68

When we run it, we discover it's already |GREEN|! Oh no!

Turns out our previous change already solved this problem. This happens sometimes in TDD, so to deal with it, we revert our previous change and make sure this test becomes |RED| - and carefully check that it failed properly. Happily, this is the case for this particular test.

Let's Recap

We now have our first implementation of the LineMonitor. It essentially works, but it's still has its problems. We'll tackle these problems later in this tutorial, but first, let's do a short recap.