Milan
Milan

Reputation: 1740

Mocking with PyTest: Improving my XML write/parse unit test

So I am rather new with pytest and mock, but still have experience with junit and mocking with mockito for groovy (which comes with an handy when(...).thenAnswer/Return function)

I wrote a simple class to parse and write xml files. This class sole purpose for existence is to be mocked in order to unit test the plugin I am currently working on. This personal project is also used as a learning tool to help me in my work duties (devOps python based)

Obviously, I needed to test it too.

Here is the class:

from lxml import etree

from organizer.tools.exception_tools import ExceptionPrinter


class XmlFilesOperations(object):

    @staticmethod
    def write(document_to_write, target):
        document_to_write.write(target, pretty_print=True)

    @staticmethod
    def parse(file_to_parse):
        parser = etree.XMLParser(remove_blank_text=True)

        try:
            return etree.parse(file_to_parse, parser)
        except Exception as something_happened:
            ExceptionPrinter.print_exception(something_happened)

And here is the unit test for it:

import mock

from organizer.tools.xml_files_operations import XmlFilesOperations

FILE_NAME = "toto.xml"

@mock.patch('organizer.tools.xml_files_operations.etree.ElementTree')
def test_write(mock_document):

    XmlFilesOperations.write(mock_document, FILE_NAME)
    mock_document.write.assert_called_with(FILE_NAME, pretty_print=True)

@mock.patch('organizer.tools.xml_files_operations.etree')
def test_parse(mock_xml):

    XmlFilesOperations.parse(FILE_NAME)
    mock_xml.parse.assert_called()

Also, here is the pipfile used for this python environment:

[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"

[packages]
lxml = "*"
pytest = "*"
pytest-lazy-fixture = "*"
mock = "*"
MKLpy = "*"

I would like to improve this test by making use of the assert_called_with function in the test_parse function. However to make it work I need to get the exact parser that is used in the XmlFilesOperations.parse method so I imagined mocking it too. For this I need the etree.XMLParser(remove_blank_text=True) call to return a mocked object

Here is what I tried:

import mock
import pytest
from lxml import etree

from organizer.tools.xml_files_operations import XmlFilesOperations

FILE_NAME = "toto.xml"

@pytest.fixture()
def mock_parser():
    parser = mock.patch('organizer.tools.xml_files_operations.etree.XMLParser').start()
    with mock.patch('organizer.tools.xml_files_operations.etree.XMLParser', return_value=parser):
        yield parser

    parser.stop()

@mock.patch('organizer.tools.xml_files_operations.etree')
def test_parse(mock_xml, mock_parser):

    XmlFilesOperations.parse(FILE_NAME)
    mock_xml.parse.assert_called_with(FILE_NAME, mock_parser)

I obtain the following error:

    def raise_from(value, from_value):
>       raise value
E       AssertionError: expected call not found.
E       Expected: parse('toto.xml', <MagicMock name='XMLParser' id='65803280'>)
E       Actual: parse('toto.xml', <MagicMock name='etree.XMLParser()' id='66022384'>)

So the mocked object returned by the call is not the same mocked object that I created.

With Mockito, I would have done something like this:

parser = etree.XmlParser()
when(etree.XMLParser(any()).thenReturn(parser)

And it would work. How could I fix that ?

Upvotes: 2

Views: 2468

Answers (1)

MrBean Bremen
MrBean Bremen

Reputation: 16805

The main problem with your approach is the sequence of mocking the objects. The fixture is called first, and while mocking the parser, it does not use the mocked etree, but the real one, while in the test the parser is used from the mocked etree, which is another mock created by that mock.
Additionally, you did check for the parser method instead of the parser itself.

Here is what should work without using a fixture:

@mock.patch('organizer.tools.xml_files_operations.etree.XMLParser')
@mock.patch('organizer.tools.xml_files_operations.etree')
def test_parse(mock_xml, mock_parser):
    XmlFilesOperations.parse(FILE_NAME)
    mock_xml.parse.assert_called_with(FILE_NAME, mock_parser())

Another possibility is to exchange the fixture and the patch, so that they are used in the correct order:

@pytest.fixture()
def mock_etree():
    with mock.patch('organizer.tools.xml_files_operations.etree') as mocked_etree:
        yield mocked_etree


@mock.patch('organizer.tools.xml_files_operations.etree.XMLParser')
def test_parse(mock_xml_parser, mock_etree):
    XmlFilesOperations.parse(FILE_NAME)
    mock_etree.parse.assert_called_with(FILE_NAME, mock_xml_parser())

Finally, if you want to use only fixtures, you can make them dependent on each other:

@pytest.fixture()
def mock_etree():
    with mock.patch('organizer.tools.xml_files_operations.etree') as mocked_etree:
        yield mocked_etree


@pytest.fixture()
def mock_parser(mock_etree):
    parser = mock.Mock()
    with mock.patch.object(mock_etree, 'XMLParser', parser):
        yield parser


def test_parse(mock_parser, mock_etree):
    XmlFilesOperations.parse(FILE_NAME)
    mock_etree.parse.assert_called_with(FILE_NAME, mock_parser())

Upvotes: 3

Related Questions