|
| 1 | +# Example code for testing Python log output |
| 2 | + |
| 3 | +This is example code that shows how you can capture log messages using |
| 4 | +the Python [`logging` |
| 5 | +module](https://docs.python.org/3/library/logging.html) for tests (or |
| 6 | +other purposes). I wrote this code for [a blog |
| 7 | +post](https://airtower.wordpress.com/2020/07/07/testing-python-log-output/), |
| 8 | +which you can also read below. |
| 9 | + |
| 10 | +## Testing Python log output |
| 11 | + |
| 12 | +Imagine you have a module and want to make sure everything’s working, |
| 13 | +so you test. For functions the idea is usually pretty simple: Provide |
| 14 | +some inputs, see if the output looks as expected. But what the |
| 15 | +function is also supposed to write some log messages and you want to |
| 16 | +check if they look the way they should? I was asking myself that |
| 17 | +question on the weekend and found a solution that I think is fun: |
| 18 | +Adding a custom log handler! |
| 19 | + |
| 20 | +Maybe you’ve already used the |
| 21 | +[`logging`](https://docs.python.org/3/library/logging.html) module? |
| 22 | +Logging messages is pretty simple, for example: |
| 23 | + |
| 24 | +```python |
| 25 | +import logging |
| 26 | +logger = logging.getLogger(__name__) |
| 27 | + |
| 28 | +def add(*vargs): |
| 29 | + logger.debug('Calculating the sum of %s', vargs) |
| 30 | + res = sum(vargs) |
| 31 | + logger.debug('Result is %d', res) |
| 32 | + return res |
| 33 | +``` |
| 34 | + |
| 35 | +Let’s assume you have that in a file `add.py`, and are going to write |
| 36 | +tests in a separate file. A simple |
| 37 | +[`unittest`](https://docs.python.org/3/library/unittest.html)-based |
| 38 | +functionality test might look like this: |
| 39 | + |
| 40 | +```python |
| 41 | +import add |
| 42 | +import unittest |
| 43 | + |
| 44 | +class AddTest(unittest.TestCase): |
| 45 | + def test_add(self): |
| 46 | + self.assertEqual(add.add(1, 2, 3), 6) |
| 47 | +``` |
| 48 | + |
| 49 | +But what if you want to check if the debug messages are logged as |
| 50 | +expected? I realized you can tap into the logging framework for that! |
| 51 | +The logging module uses _handlers_ to send log messages wherever they |
| 52 | +are supposed to go, and you can add as many as you want, so why not |
| 53 | +add one that sends output to the test? |
| 54 | + |
| 55 | +First you’re going to need a logger for the target module: |
| 56 | + |
| 57 | +```python |
| 58 | + logger = logging.getLogger('add') |
| 59 | + logger.setLevel(logging.DEBUG) |
| 60 | +``` |
| 61 | + |
| 62 | +Setting the level of the logger is necessary to ensure you really get |
| 63 | +all messages (unless that has been set elsewhere already), but keep in |
| 64 | +mind that that’s a process-wide setting. In a simple unit test like |
| 65 | +here that’s not going to cause trouble. Now that we have the logger we |
| 66 | +need to add a handler. |
| 67 | + |
| 68 | +Also I want to have the input parameters and expected result in |
| 69 | +variables, so I can use them later for comparing with the log |
| 70 | +messages: |
| 71 | + |
| 72 | +```python |
| 73 | + params = (1, 2, 3) |
| 74 | + expected_sum = 6 |
| 75 | +``` |
| 76 | + |
| 77 | +### Option one: A temporary log file |
| 78 | + |
| 79 | +```python |
| 80 | + with tempfile.SpooledTemporaryFile(mode='w+') as log: |
| 81 | + handler = logging.StreamHandler(stream=log) |
| 82 | + logger.addHandler(handler) |
| 83 | + try: |
| 84 | + self.assertEqual(add.add(*params), expected_sum) |
| 85 | + finally: |
| 86 | + logger.removeHandler(handler) |
| 87 | + log.seek(0) |
| 88 | + logdata = log.read() |
| 89 | +``` |
| 90 | + |
| 91 | +The |
| 92 | +[`logging.StreamHandler`](https://docs.python.org/3/library/logging.handlers.html#logging.StreamHandler) |
| 93 | +can write to all sorts of streams, its default is `sys.stderr`. I’m |
| 94 | +using a |
| 95 | +[`tempfile.SpooledTemporaryFile`](https://docs.python.org/3/library/tempfile.html#tempfile.SpooledTemporaryFile) |
| 96 | +as the target because it is automatically cleaned up as soon as it is |
| 97 | +closed, and the amount of log data will be small, so it makes sense to |
| 98 | +keep it in memory. |
| 99 | + |
| 100 | +The try/finally block around the function I’m testing ensures the |
| 101 | +handler is always removed after the function call, even in case of an |
| 102 | +exception (including those from failed assertions). |
| 103 | + |
| 104 | +In the end you just have to read the file and check if the output looks like it should. |
| 105 | + |
| 106 | +```python |
| 107 | + lines = logdata.splitlines() |
| 108 | + self.assertEqual(lines[0], f'Calculating the sum of {params!s}') |
| 109 | + self.assertEqual(lines[1], f'Result is {expected_sum}') |
| 110 | +``` |
| 111 | + |
| 112 | +This also shows the disadvantages of this method: You end up with a |
| 113 | +wall of text that you have to parse. With two lines it’s not too bad, |
| 114 | +but with a lot of output it may get messy. |
| 115 | + |
| 116 | +You can remedy that somewhat by [attaching a |
| 117 | +`Formatter`](https://docs.python.org/3/library/logging.html#logging.Handler.setFormatter) |
| 118 | +to the handler, which as the name indicates lets you format the log |
| 119 | +messages, including adding some metadata. |
| 120 | + |
| 121 | +### Option two: A message queue |
| 122 | + |
| 123 | +```python |
| 124 | + q = queue.SimpleQueue() |
| 125 | + handler = logging.handlers.QueueHandler(q) |
| 126 | + logger.addHandler(handler) |
| 127 | + try: |
| 128 | + self.assertEqual(add.add(*params), expected_sum) |
| 129 | + finally: |
| 130 | + logger.removeHandler(handler) |
| 131 | +``` |
| 132 | + |
| 133 | +This code is a bit shorter, because there’s no file to open. Instead |
| 134 | +the log messages are added to the |
| 135 | +[`queue`](https://docs.python.org/3/library/queue.html), and I can |
| 136 | +retrieve them message by message: |
| 137 | + |
| 138 | +```python |
| 139 | + self.assertEqual(q.get_nowait().getMessage(), |
| 140 | + f'Calculating the sum of {params!s}') |
| 141 | + self.assertEqual(q.get_nowait().getMessage(), |
| 142 | + f'Result is {expected_sum}') |
| 143 | +``` |
| 144 | +This has two advantages: |
| 145 | + |
| 146 | +1. I always get complete messages, no need to worry about splitting |
| 147 | + lines and newlines in messages. |
| 148 | +2. The objects in the queue are not strings, they are |
| 149 | + [`LogRecord`](https://docs.python.org/3/library/logging.html#logging.LogRecord) |
| 150 | + objects, which hold all the metadata of the log message. Though in |
| 151 | + this example I’m just like “give me the message” and that’s it. |
| 152 | + |
| 153 | +### Conclusion |
| 154 | + |
| 155 | +Turns out the Python `logging` module is easier to use than I had |
| 156 | +thought when I started figuring this out, and is fun to play with. Of |
| 157 | +course with more complex tests this kind of analysis might get more |
| 158 | +complex, too: You might not want to look at every message (maybe a |
| 159 | +Filter helps?), or you might not be sure which order they arrive in. |
| 160 | + |
| 161 | +Have fun coding! |
0 commit comments