Mocking ftplib.FTP for unit testing Python code

I suggest using pytest and pytest-mock.

from pytest_mock import mocker


def test_download_file(mocker):
    ftp_constructor_mock = mocker.patch('ftplib.FTP')
    ftp_mock = ftp_constructor_mock.return_value

    download_file('ftp.server.local', 'pub/files', 'wanted_file.txt')

    ftp_constructor_mock.assert_called_with('ftp.server.local')
    assert ftp_mock.login.called
    ftp_mock.cwd.assert_called_with('pub/files')

When you do patch(ftplib.FTP) you are patching FTP constructor. dowload_file() use it to build ftp object so your ftp object on which you call login() and cmd() will be mock_ftp.return_value instead of mock_ftp.

Your test code should be follow:

class TestDownloader(unittest.TestCase):

    @patch('ftplib.FTP', autospec=True)
    def test_download_file(self, mock_ftp_constructor):
        mock_ftp = mock_ftp_constructor.return_value
        download_file('ftp.server.local', 'pub/files', 'wanted_file.txt')
        mock_ftp_constructor.assert_called_with('ftp.server.local')
        self.assertTrue(mock_ftp.login.called)
        mock_ftp.cwd.assert_called_with('pub/files')

I added all checks and autospec=True just because is a good practice


Like Ibrohim's answer, I prefer pytest with mocker.

I have went a bit further and have actually wrote a library which helps me to mock easily. Here is how to use it for your case.

You start by having your code and a basic pytest function, with the addition of my helper library to generate mocks to modules and the matching asserts generation:

import ftplib

from mock_autogen.pytest_mocker import PytestMocker


def download_file(hostname, file_path, file_name):
    ftp = ftplib.FTP(hostname)
    ftp.login()
    ftp.cwd(file_path)


def test_download_file(mocker):
    import sys
    print(PytestMocker(mocked=sys.modules[__name__],
                       name=__name__).mock_modules().prepare_asserts_calls().generate())
    download_file('ftp.server.local', 'pub/files', 'wanted_file.txt')

When you run the test for the first time, it would fail due to unknown DNS, but the print statement which wraps my library would give us this valuable input:

...
mock_ftplib = mocker.MagicMock(name='ftplib')
mocker.patch('test_29817963.ftplib', new=mock_ftplib)
...
import mock_autogen
...
print(mock_autogen.generator.generate_asserts(mock_ftplib, name='mock_ftplib'))

I'm placing this in the test and would run it again:

def test_download_file(mocker):
    mock_ftplib = mocker.MagicMock(name='ftplib')
    mocker.patch('test_29817963.ftplib', new=mock_ftplib)

    download_file('ftp.server.local', 'pub/files', 'wanted_file.txt')

    import mock_autogen
    print(mock_autogen.generator.generate_asserts(mock_ftplib, name='mock_ftplib'))

This time the test succeeds and I only need to collect the result of the second print to get the proper asserts:

def test_download_file(mocker):
    mock_ftplib = mocker.MagicMock(name='ftplib')
    mocker.patch(__name__ + '.ftplib', new=mock_ftplib)

    download_file('ftp.server.local', 'pub/files', 'wanted_file.txt')

    mock_ftplib.FTP.assert_called_once_with('ftp.server.local')
    mock_ftplib.FTP.return_value.login.assert_called_once_with()
    mock_ftplib.FTP.return_value.cwd.assert_called_once_with('pub/files')

If you would like to keep using unittest while using my library, I'm accepting pull requests.