Код модульного тестирования с зависимостью файловой системы

Я пишу компонент, который, учитывая ZIP файл, должен:

  • Разархивируйте файл.
  • Найдите определенную dll среди распакованных файлов.
  • Загрузите эту DLL через отражение и вызовите на ней метод.

Я хотел бы unit test этот компонент.

У меня возникает соблазн написать код, который напрямую связан с файловой системой:

void DoIt()
{
   Zip.Unzip(theZipFile, "C:\\foo\\Unzipped");
   System.IO.File myDll = File.Open("C:\\foo\\Unzipped\\SuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

Но люди часто говорят: "Не пишите модульные тесты, которые полагаются на файловую систему, базу данных, сеть и т.д."

Если бы я написал это в дружественном модульном тестировании, я предполагаю, что это будет выглядеть так:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Ура! Теперь это можно проверить; Я могу комбинировать тестовые двойники (mocks) с методом DoIt. Но какой ценой? Теперь я должен был определить 3 новых интерфейса, чтобы сделать это возможным. И что, собственно, я тестирую? Я тестирую, что моя функция DoIt правильно взаимодействует со своими зависимостями. Он не проверяет правильность распаковки почтового файла и т.д.

Не похоже, что я больше тестирую функциональность. Похоже, я просто тестирую взаимодействия между классами.

Мой вопрос в том, что: какой правильный способ для unit test что-то зависит от файловой системы?

edit Я использую .NET, но в эту концепцию может также входить Java или собственный код.

Ответ 1

В этом нет ничего плохого, это просто вопрос о том, называете ли вы его unit test или тестом интеграции. Вам просто нужно убедиться, что если вы взаимодействуете с файловой системой, нет никаких непредвиденных побочных эффектов. В частности, убедитесь, что вы очистились после себя - удалите все временные файлы, которые вы создали, и что вы случайно не перезаписали существующий файл, у которого было то же имя файла, что и временный файл, который вы использовали. Всегда используйте относительные пути, а не абсолютные пути.

Также было бы полезно chdir() во временном каталоге перед запуском теста и chdir() назад.

Ответ 2

Ура! Теперь это можно проверить; Я могу комбинировать тестовые двойники (mocks) с методом DoIt. Но какой ценой? Теперь я должен был определить 3 новых интерфейса, чтобы сделать это возможным. И что, собственно, я тестирую? Я тестирую, что моя функция DoIt правильно взаимодействует со своими зависимостями. Он не проверяет правильность распаковки почтового файла и т.д.

Вы ударили гвоздь прямо на голову. То, что вы хотите протестировать, - это логика вашего метода, а не обязательно, можно ли обратиться к истинному файлу. Вам не нужно тестировать (в этом unit test), правильно ли распакован файл, ваш метод принимает это как должное. Интерфейсы ценны сами по себе, потому что они предоставляют абстракции, которые вы можете программировать против, а не явно или явно полагаться на одну конкретную реализацию.

Ответ 3

В вашем вопросе вы найдете одну из самых сложных частей тестирования для разработчиков, которые просто попадают в нее:

"Что, черт возьми, я тестирую?"

Ваш пример не очень интересен, потому что он просто склеивает некоторые вызовы API, поэтому, если вы должны написать unit test для этого, вы должны просто утверждать, что были вызваны методы. Тесты, подобные этому, тесно связаны с вашими деталями реализации теста. Это плохо, потому что теперь вы должны менять тест каждый раз, когда вы изменяете детали реализации вашего метода, потому что изменение деталей реализации нарушает ваши тесты!

Наличие плохих тестов на самом деле хуже, чем отсутствие тестов вообще.

В вашем примере:

void DoIt(IZipper zipper, IFileSystem fileSystem, IDllRunner runner)
{
   string path = zipper.Unzip(theZipFile);
   IFakeFile file = fileSystem.Open(path);
   runner.Run(file);
}

Пока вы можете передавать в mocks, в методе тестирования нет никакой логики. Если вы попытаетесь выполнить unit test для этого, это может выглядеть примерно так:

// Assuming that zipper, fileSystem, and runner are mocks
void testDoIt()
{
  // mock behavior of the mock objects
  when(zipper.Unzip(any(File.class)).thenReturn("some path");
  when(fileSystem.Open("some path")).thenReturn(mock(IFakeFile.class));

  // run the test
  someObject.DoIt(zipper, fileSystem, runner);

  // verify things were called
  verify(zipper).Unzip(any(File.class));
  verify(fileSystem).Open("some path"));
  verify(runner).Run(file);
}

Поздравляем, вы в основном скопировали детали реализации вашего метода DoIt() в тест. Счастливое сохранение.

Когда вы пишете тесты, вы хотите проверить WHAT, а не HOW. Подробнее см. Black Box Testing.

WHAT - это имя вашего метода (или, по крайней мере, оно должно быть). HOW - это все небольшие детали реализации, которые живут внутри вашего метода. Хорошие тесты позволяют вам заменить КАК, не нарушая ЧТО.

Подумайте об этом так, спросите себя:

"Если я изменю детали реализации этого метода (не изменяя публичный контракт), он нарушит мои тесты?"

Если да, вы тестируете КАК, а не ЧТО.

Чтобы ответить на ваш конкретный вопрос о тестировании кода с помощью зависимостей файловой системы, скажем, у вас было что-то более интересное, связанное с файлом, и вы хотели сохранить кодированное содержимое Base64 в файле byte[] в файл. Вы можете использовать потоки для этого, чтобы проверить, что ваш код работает правильно, не проверяя , как он это делает. Одним из примеров может быть что-то вроде этого (в Java):

interface StreamFactory {
    OutputStream outStream();
    InputStream inStream();
}

class Base64FileWriter {
    public void write(byte[] contents, StreamFactory streamFactory) {
        OutputStream outputStream = streamFactory.outStream();
        outputStream.write(Base64.encodeBase64(contents));
    }
}

@Test
public void save_shouldBase64EncodeContents() {
    OutputStream outputStream = new ByteArrayOutputStream();
    StreamFactory streamFactory = mock(StreamFactory.class);
    when(streamFactory.outStream()).thenReturn(outputStream);

    // Run the method under test
    Base64FileWriter fileWriter = new Base64FileWriter();
    fileWriter.write("Man".getBytes(), streamFactory);

    // Assert we saved the base64 encoded contents
    assertThat(outputStream.toString()).isEqualTo("TWFu");
}

В тесте используется ByteArrayOutputStream, но в приложении (с использованием инъекции зависимостей) реальный StreamFactory (возможно, называемый FileStreamFactory) возвращает FileOutputStream из outputStream() и будет писать в File.

Что было интересно о методе write здесь, так это то, что он записывал содержимое из кодировки Base64, так что мы тестировали. Для вашего метода DoIt() это было бы более целесообразно протестировано с помощью тега интеграции .

Ответ 4

Я сдержанно загрязняю свой код типами и концепциями, которые существуют только для облегчения модульного тестирования. Конечно, если это делает дизайн чище и лучше, чем здорово, но я думаю, что это часто бывает не так.

Мое мнение об этом заключается в том, что ваши модульные тесты будут делать столько, сколько они могут, что не может быть 100% -ным охватом. Фактически, это может быть только 10%. Дело в том, что ваши юнит-тесты должны быть быстрыми и не иметь внешних зависимостей. Они могут проверять такие случаи, как "этот метод генерирует исключение ArgumentNullException, когда вы передаете значение null для этого параметра".

Затем я бы добавил интеграционные тесты (также автоматизированные и, вероятно, используя ту же инфраструктуру модульного тестирования), которые могут иметь внешние зависимости и тестировать сквозные сценарии, такие как эти.

При измерении покрытия кода я измеряю как единичные, так и интеграционные тесты.

Ответ 5

Нет ничего плохого в попадании в файловую систему, просто рассмотрите ее как тест интеграции, а не unit test. Я бы поменял путь с жесткой кодировкой с относительным путем и создал подпапку TestData, чтобы содержать zips для модульных тестов.

Если ваши тесты интеграции занимают слишком много времени, а затем отделяют их, поэтому они не работают так часто, как ваши быстрые модульные тесты.

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

Ответ 6

Один из способов - написать метод распаковки для ввода InputStreams. Затем unit test может построить такой InputStream из байтового массива, используя ByteArrayInputStream. Содержимое этого байтового массива может быть константой в коде unit test.

Ответ 7

Похоже, что это скорее интеграционный тест, так как вы зависите от конкретной детали (файловой системы), которая может быть теоретически изменена.

Я бы отвлек код, который касается ОС в его собственный модуль (класс, сборка, банку, что угодно). В вашем случае вы хотите загрузить определенную DLL, если она найдена, поэтому создайте интерфейс IDllLoader и класс DllLoader. Попросите ваше приложение получить DLL из DllLoader с помощью интерфейса и проверить, что.. вы не несете ответственность за unzip-код после полного права?

Ответ 8

Предполагая, что "взаимодействия файловой системы" хорошо протестированы в самой структуре, создайте свой метод для работы с потоками и проверьте это. Открытие FileStream и передача его методу могут быть исключены из ваших тестов, так как FileStream.Open хорошо протестирован создателями фреймворка.

Ответ 9

Вы не должны проверять взаимодействие классов и функции. вместо этого вы должны рассмотреть интеграционное тестирование. Проверьте требуемый результат, а не операцию загрузки файла.

Ответ 10

Для unit test я бы предположил, что вы включаете тестовый файл в свой проект (файл EAR или его эквивалент), затем используйте относительный путь в модульных тестах, то есть "../testdata/testfile".

До тех пор, пока ваш проект будет правильно экспортирован/импортирован, чем ваш unit test должен работать.

Ответ 11

Как говорили другие, первый вариант - это тест интеграции. Второй тест должен выполнять только то, что должна выполнять функция, и это все unit test.

Как показано, второй пример выглядит немного бессмысленным, но он дает вам возможность проверить, как функция реагирует на ошибки на любом из шагов. В примере нет проверок ошибок, но в реальной системе у вас может быть, и инъекция зависимостей позволит вам протестировать все ответы на любые ошибки. Тогда стоимость будет стоить того.