Python unit-testing a complicated method

Question:

I am looking for any advice on how to unit test the following method.

def delete_old_files(self):
        """Deletes all recordings older then a predefined time period.

        Checks the mtime of files in _store_dir and if it is older then the predefined time period
        delete them.
        """
        now = time.time()
        self.logger.info(
            f"Delete: Looking for files older then {self.config.delete.older_then}"
            f" days in: {self._store_dir}."
        )
        try:
            for root, dirs, files in os.walk(self._store_dir):
                for file in files:
                    try:
                        if file == ".gitignore":
                            continue
                        file = os.path.join(root, file)
                        # check if the mtime is older then x days (x * hours * minutes * seconds)
                        if os.stat(file).st_mtime < now - (
                            self.config.delete.older_then * 24 * 60 * 60
                        ):
                            if os.path.isfile(file):
                                os.remove(file)
                                self.logger.info(f"Delete:t{file}")
                    except FileNotFoundError:
                        self.logger.warning(
                            f'Could not find "{file}". Continue without deleting this file.'
                        )
                    except PermissionError:
                        self.logger.warning(
                            f'Permission denied when trying to delete "{file}".'
                            " Continue without deleting this file."
                        )
        # TODO: Do we want to handle the exception here or higher up?
        # Should we treat this as unacceptable or not?
        except FileNotFoundError:
            self.logger.warning(
                f"Directory not found! Could not find {self._store_dir}"
                " Continue without deleting old files."
            )
        except PermissionError:
            self.logger.warning(
                f"Permission denied! Could not access {self._store_dir}"
                " Continue without deleting old files."
            )

A little bit of background information: I am writing a audio recorder which saves all files into self._store_dir and the method I want to test is kind of an optional garbage collector. Files older then self.config.delete.older_then should be deleted. Any advice on how to unit test such a method or refactor it so it becomes easier to test is very welcome.

Ideas I have are:

  • mock the filesystem and provided all the different cases. But that is the opposite of what a unit-test should do, right?
  • split up the method into smaller method and test them on their own.
Asked By: Loxbie

||

Answers:

I kind of figured it out myself. I refactored my code so it does not lie about the information it needs to do what it does. Then I added a return value I can test for if something breaks or everything goes well. I found this great blog about unit testing in python which helped me a lot.

For completeness, here is my refactored code:

    def delete_old_files(self, store_dir: str, older_then: int) -> bool:
        """Deletes all recordings older then a predefined time period.

        Checks the mtime of files in store_dir and if it is older then the predefined time period,
        delete them.

        Args:
            store_dir: The file path as a string to the directory to check for files to delete.
            older_then: The minimum age of the files in days which will be deleted.

        Returns:
            True, if the method could delete all files older than the minimum age.
            This is also True if no files were found and therefore no files were deleted.

            False, if the method encounters an FileNotFoundError or PermissionError
            for at least one file.
        """
        now = time.time()
        self.logger.info(
            f"Delete: Looking for files older then {older_then}" f" days in: {store_dir}."
        )
        no_deletion_problems = True
        for root, dirs, files in os.walk(store_dir):
            for file in files:
                try:
                    if file == ".gitignore":
                        continue
                    file = os.path.join(root, file)
                    # check if the mtime is older then x days (x * hours * minutes * seconds)
                    if os.stat(file).st_mtime < now - (older_then * 24 * 60 * 60):
                        os.remove(file)
                        self.logger.info(f"Delete:t{file}")
                except FileNotFoundError:
                    self.logger.warning(
                        f'Could not find "{file}". Continue without deleting this file.'
                    )
                    no_deletion_problems = False
                except PermissionError:
                    self.logger.warning(
                        f'Permission denied when trying to delete "{file}".'
                        " Continue without deleting this file."
                    )
                    no_deletion_problems = False
        return no_deletion_problems

And if it helps anyone I will post my tests as well:

    def test_delete_old_files(self):
        """Test to delete old files."""
        raw_dict = {"name": "log_test", "file": None, "level": "INFO"}
        log_config = recorder.Config.Logger(raw_dict)
        log_handler = recorder.LogHandler(log_config)
        logger = log_handler.get_logger()
        self.recorder.logger = logger

        with mock.patch("os.walk") as mock_oserror:
            mock_oserror.return_value = [("root", "dirs", ["file1, file2", ".gitignore"])]

            self.assertFalse(self.recorder.delete_old_files("dir_to_check", 1))

    class stat_obj:
        """Dummy stat class."""

        st_mtime = 0

    @patch.multiple(
        "os",
        walk=MagicMock(return_value=[("root", "dirs", ["file1, file2", ".gitignore"])]),
        stat=MagicMock(return_value=stat_obj()),
        remove=MagicMock(return_value=None),
    )
    def test_delete_old_files_mocking_os_stat(self, **mocks):
        """Test to delte old files when mocking os.stat()."""
        raw_dict = {"name": "log_test", "file": None, "level": "INFO"}
        log_config = recorder.Config.Logger(raw_dict)
        log_handler = recorder.LogHandler(log_config)
        logger = log_handler.get_logger()
        self.recorder.logger = logger

        self.assertTrue(self.recorder.delete_old_files("dir_to_check", 1))

    @patch.multiple(
        "os",
        walk=MagicMock(return_value=[("root", "dirs", ["file1, file2", ".gitignore"])]),
        stat=MagicMock(return_value=stat_obj()),
        remove=MagicMock(side_effect=PermissionError),
    )
    def test_delete_old_files_mocking_permission_error(self, **mocks):
        """Test to delte old files when mocking for a permission error."""
        raw_dict = {"name": "log_test", "file": None, "level": "INFO"}
        log_config = recorder.Config.Logger(raw_dict)
        log_handler = recorder.LogHandler(log_config)
        logger = log_handler.get_logger()
        self.recorder.logger = logger

        self.assertFalse(self.recorder.delete_old_files("dir_to_check", 1))

I went with mocking and moved some unnecessary checks further up in my code. All in all it’s not perfect but good enough for me right now.

Answered By: Loxbie