Як виконати модульне тестування функцій, що записують файли, використовуючи Python-модуль


83

У мене є функція Python, яка записує вихідний файл на диск.

Я хочу написати для нього модульний тест за допомогою unittestмодуля Python .

Як слід стверджувати рівність файлів? Я хотів би отримати помилку, якщо вміст файлу відрізняється від очікуваного + перелік відмінностей. Як і на виході команди Unix diff .

Чи існує офіційний або рекомендований спосіб зробити це?

Відповіді:


50

Найпростіше написати вихідний файл, потім прочитати його вміст, прочитати вміст золотого (очікуваного) файлу та порівняти їх із простою рівною рядка. Якщо вони однакові, видаліть вихідний файл. Якщо вони різні, висловіть твердження.

Таким чином, після закінчення тестів кожен невдалий тест буде представлений вихідним файлом, і ви можете скористатися стороннім інструментом, щоб порівняти їх із золотими файлами ( Beyond Compare чудово для цього).

Якщо ви дійсно хочете надати власний вихідний результат, пам’ятайте, що stdlib Python має модуль difflib. Нова підтримка unittest в Python 3.1 включає assertMultiLineEqualметод, який використовує його для відображення відмінностей, подібний до цього:

    def assertMultiLineEqual(self, first, second, msg=None):
        """Assert that two multi-line strings are equal.

        If they aren't, show a nice diff.

        """
        self.assertTrue(isinstance(first, str),
                'First argument is not a string')
        self.assertTrue(isinstance(second, str),
                'Second argument is not a string')

        if first != second:
            message = ''.join(difflib.ndiff(first.splitlines(True),
                                                second.splitlines(True)))
            if msg:
                message += " : " + msg
            self.fail("Multi-line strings are unequal:\n" + message)

ні, найкращий загальний спосіб - це не писати у файл, який може бути повільним та спричиненим помилками (prod env може бути зовсім іншим, ніж test / CI env, наприклад, Windows проти OSX), а замість цього знущатися із дзвінка, openяк описано в інших відповідях на цій сторінці, використовуючи unittest.mock(див. відповідь Енріко М)
Ерік

71

Я вважаю за краще, щоб функції виводу явно приймали дескриптор файлу (або подібний до файлу об'єкт ), а не приймали ім'я файлу та самі відкривали файл. Таким чином, я можу передати StringIOоб'єкт функції виводу в моєму модульному тесті, потім .read()вміст назад від цього StringIOоб'єкта (після .seek(0)виклику) і порівняти з моїм очікуваним результатом.

Наприклад, ми перенесли б такий код

##File:lamb.py
import sys


def write_lamb(outfile_path):
    with open(outfile_path, 'w') as outfile:
        outfile.write("Mary had a little lamb.\n")


if __name__ == '__main__':
    write_lamb(sys.argv[1])



##File test_lamb.py
import unittest
import tempfile

import lamb


class LambTests(unittest.TestCase):
    def test_lamb_output(self):
        outfile_path = tempfile.mkstemp()[1]
        try:
            lamb.write_lamb(outfile_path)
            contents = open(tempfile_path).read()
        finally:
            # NOTE: To retain the tempfile if the test fails, remove
            # the try-finally clauses
            os.remove(outfile_path)
        self.assertEqual(result, "Mary had a little lamb.\n")

кодувати так

##File:lamb.py
import sys


def write_lamb(outfile):
    outfile.write("Mary had a little lamb.\n")


if __name__ == '__main__':
    with open(sys.argv[1], 'w') as outfile:
        write_lamb(outfile)



##File test_lamb.py
import unittest
from io import StringIO

import lamb


class LambTests(unittest.TestCase):
    def test_lamb_output(self):
        outfile = StringIO()
        # NOTE: Alternatively, for Python 2.6+, you can use
        # tempfile.SpooledTemporaryFile, e.g.,
        #outfile = tempfile.SpooledTemporaryFile(10 ** 9)
        lamb.write_lamb(outfile)
        outfile.seek(0)
        content = outfile.read()
        self.assertEqual(content, "Mary had a little lamb.\n")

Цей підхід має додаткову перевагу, що робить вашу функцію виводу більш гнучкою, якщо, наприклад, ви вирішили, що не хочете писати у файл, а в інший буфер, оскільки він прийматиме всі файлоподібні об'єкти.

Зауважте, що використання StringIOприпускає, що вміст тестового результату може поміститися в основну пам’ять. Для дуже великих результатів ви можете використовувати підхід до тимчасових файлів (наприклад, tempfile.SpooledTemporaryFile ).


2
Це краще, ніж записати файл на диск. Якщо у вас багато тонн unittests, IO на диск викликає всілякі проблеми, особливо намагаючись їх очистити. У мене були тести на запис на диск, tearDown видалив записані файли. Тести працювали б добре по черзі, а потім не працювали під час запуску всіх. Принаймні з Visual Studio та PyTools на машині Win. Крім того, швидкість.
srock

1
Хоча це гарне рішення для тестування окремих функцій, все одно клопіт при тестуванні фактичного інтерфейсу, який надає ваша програма (наприклад, інструмент CLI) ..
Joost

1
Я отримав помилку: TypeError: очікується аргумент Unicode, отримано 'str'
cn123h

Я прийшов сюди, тому що намагаюся писати модульні тести для прогулянок та читання розділених паркетних наборів даних за файлом. Для цього потрібно проаналізувати шлях до файлу, щоб отримати пари ключ / значення, щоб призначити відповідне значення розділу (в кінцевому рахунку) результуючій пані DataFrame. Запис у буфер, хоча і приємний, не дає мені можливості аналізувати значення розділів.
PMende,

1
@PMende Здається, ви працюєте з API, який потребує взаємодії з фактичною файловою системою. Одиничні тести не завжди є відповідним рівнем тестування. Не можна перевіряти всі частини коду на рівні модульних тестів; також слід застосовувати інтеграційні або системні тести, де це доречно. Спробуйте, однак, містити ці частини і передавати між межами лише прості значення, коли це можливо. See youtube.com/watch?v=eOYal8elnZk
gotgenes

20
import filecmp

Тоді

self.assertTrue(filecmp.cmp(path1, path2))

2
За замовчуванням це робить shallowпорівняння, яке перевіряє лише метадані файлів (mtime, розмір тощо). Будь ласка, додайте shallow=Falseу своєму прикладі.
фамза

2
Крім того, результати кешуються .
фамза

12

Я завжди намагаюся уникати запису файлів на диск, навіть якщо це тимчасова папка, присвячена моїм тестам: якщо насправді не торкатися диска, ваші тести набагато швидші, особливо якщо ви багато взаємодієте з файлами у своєму коді.

Припустимо, у вас є ця «дивовижна» частина програмного забезпечення у файлі під назвою main.py:

"""
main.py
"""

def write_to_file(text):
    with open("output.txt", "w") as h:
        h.write(text)

if __name__ == "__main__":
    write_to_file("Every great dream begins with a dreamer.")

Щоб протестувати write_to_fileметод, ви можете написати щось подібне у файлі в тій же папці, що називається test_main.py:

"""
test_main.py
"""
from unittest.mock import patch, mock_open

import main


def test_do_stuff_with_file():
    open_mock = mock_open()
    with patch("main.open", open_mock, create=True):
        main.write_to_file("test-data")

    open_mock.assert_called_with("output.txt", "w")
    open_mock.return_value.write.assert_called_once_with("test-data")

3

Ви можете відокремити створення вмісту від обробки файлів. Таким чином, ви можете перевірити правильність вмісту, не возившись з тимчасовими файлами і не очищаючи їх після цього.

Якщо ви пишете метод генератора, який дає кожен рядок вмісту, то у вас може бути метод обробки файлів, який відкриває файл і викликає file.writelines()з послідовністю рядків. Два методи навіть можуть бути в одному класі: тестовий код викликав би генератор, а виробничий код - обробник файлу.

Ось приклад, який показує всі три способи тестування. Зазвичай ви просто вибираєте один, залежно від того, які методи доступні в класі для тестування.

import os
from io import StringIO
from unittest.case import TestCase


class Foo(object):
    def save_content(self, filename):
        with open(filename, 'w') as f:
            self.write_content(f)

    def write_content(self, f):
        f.writelines(self.generate_content())

    def generate_content(self):
        for i in range(3):
            yield u"line {}\n".format(i)


class FooTest(TestCase):
    def test_generate(self):
        expected_lines = ['line 0\n', 'line 1\n', 'line 2\n']
        foo = Foo()

        lines = list(foo.generate_content())

        self.assertEqual(expected_lines, lines)

    def test_write(self):
        expected_text = u"""\
line 0
line 1
line 2
"""
        f = StringIO()
        foo = Foo()

        foo.write_content(f)

        self.assertEqual(expected_text, f.getvalue())

    def test_save(self):
        expected_text = u"""\
line 0
line 1
line 2
"""
        foo = Foo()

        filename = 'foo_test.txt'
        try:
            foo.save_content(filename)

            with open(filename, 'rU') as f:
                text = f.read()
        finally:
            os.remove(filename)

        self.assertEqual(expected_text, text)

Не могли б ви навести приклад коду для цього? Це звучить цікаво.
buhtz

1
Я додав приклад для всіх трьох підходів, @buhtz.
Дон Кіркбі,

-1

На основі пропозицій я зробив наступне.

class MyTestCase(unittest.TestCase):
    def assertFilesEqual(self, first, second, msg=None):
        first_f = open(first)
        first_str = first_f.read()
        second_f = open(second)
        second_str = second_f.read()
        first_f.close()
        second_f.close()

        if first_str != second_str:
            first_lines = first_str.splitlines(True)
            second_lines = second_str.splitlines(True)
            delta = difflib.unified_diff(first_lines, second_lines, fromfile=first, tofile=second)
            message = ''.join(delta)

            if msg:
                message += " : " + msg

            self.fail("Multi-line strings are unequal:\n" + message)

Я створив підклас MyTestCase, оскільки у мене є безліч функцій, які потребують читання / запису файлів, тому мені справді потрібно мати метод повторного використання. Тепер у своїх тестах я б підклас MyTestCase замість unittest.TestCase.

Що ви думаєте про це?


Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.