Search code examples
pythonunit-testingmocking

Again, how to use unittest.mock to test open(..., 'w') ... .write(...)?


I am not trying to do anything practical here; I am just trying to understand how to use the unittest.mock module in this particular use case. What I am attempting to do here is so simple, straightforward, and so smack down the middle of unitttest.mock's wheelhouse that I can't believe I am having such a hard time getting it to work.

This toy example shows the problem:

def fn(in1, in2, out):
    with open(in1) as f1, open(in2) as f2, open(out, 'w') as f3:
        for line in f1:
            f3.write(f'1>{line}')
        for line in f2:
            f3.write(f'2>{line}')

from unittest.mock import patch, mock_open
from io import StringIO

in1 = StringIO('a\nb\n')
in2 = StringIO('c\nd\ne\n')
out = StringIO()
expected = '1>a\n1>b\n2>c\n2>d\n2>e\n'
expected_first_line = expected.splitlines()[0] + '\n'

with patch('builtins.open', new_callable=mock_open) as fake_open:
    fake_open.side_effect = [in1, in2, out]
    fn('this', 'that', 'somethingelse')

Q: I want to know how must I modify above script so that I can test whether or not fn wrote to the write handle the string stored in expected1.

I have tried to use the answers given in

How do I mock an open(...).write() without getting a 'No such file or directory' error?

...but they don't work at all.

For example, if I attempt to follow the first answer in the post above, by adding the following lines at the end of the script above:

handle = fake_open()
handle.write.assert_called_once_with(expected_first_line)

...the assertion never runs because the assignment on the first line bombs:

Traceback (most recent call last):
  File "/tmp/try.py", line 21, in <module>
    handle = fake_open()
  File "/opt/python3/3.8.0/lib/python3.8/unittest/mock.py", line 1075, in __call__
    return self._mock_call(*args, **kwargs)
  File "/opt/python3/3.8.0/lib/python3.8/unittest/mock.py", line 1079, in _mock_call
    return self._execute_mock_call(*args, **kwargs)
  File "/opt/python3/3.8.0/lib/python3.8/unittest/mock.py", line 1136, in _execute_mock_call
    result = next(effect)
StopIteration

If I follow the second answer, and add this line

    fake_open.return_value.__enter__().write.assert_called_once_with(expected_first_line)

...the test fails with

AssertionError: Expected 'write' to be called once. Called 0 times.

...which is a bit more civilized, but still pretty hard for me to understand.

Also, it appears that, once the function returns, the out variable can no longer be read. (Every operation I've tried on out fails with a ValueError: I/O operation on closed file exception.)

Of course, I realize that I can roll my own mock object in 10-20 lines of code, but what I am trying to do here should be bread-and-butter for unittest.mock.


1Even though in some of my attempts I used the value in expected_first_line, I am really not interested in testing just one line in the output, but all of it. I hope that there is a straightforward way to do this.


Solution

  • You've set your mock to return 3 StringIO objects on 3 calls. These are ordinary StringIO objects that do not track calls.

    You cannot assert that they had their write methods called in any particular way, because that info was never recorded. If you hadn't closed the files, you could check their contents:

    # replace "assert" with an appropriate assertion method if you're using vanilla unittest
    assert out.getvalue() == expected
    

    but you closed the files, by using a with statement:

    with open(in1) as f1, open(in2) as f2, open(out, 'w') as f3:
    

    so the contents are gone, and there is no way to determine what, if anything, was written to them.


    The most straightforward way to fix this would be to use a mock instead of a StringIO object for out. You can call your fake_open to create this mock (before setting side_effect) to get a mock file object with the standard mock_open conveniences, like __enter__() returning the same mock instead of a new one:

    out = fake_open()
    fake_open.side_effect = [in1, in2, out]
    

    Then you can do things like out.write.assert_called_with(whatever), or check out.mock_calls.