Skip to content

Commit 74ea20c

Browse files
committed
README with blog post
1 parent 330e1f1 commit 74ea20c

File tree

1 file changed

+161
-0
lines changed

1 file changed

+161
-0
lines changed

README.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)