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


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

  1. распаковать файл.
  2. найти определенную dll среди распакованных файлов.
  3. загрузите эту dll через отражение и вызовите метод на нем.

Я хотел бы модульное тестирование этого компонента.

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

void DoIt()
{
   Zip.Unzip(theZipFile, "C:fooUnzipped");
   System.IO.File myDll = File.Open("C:fooUnzippedSuperSecret.bar");
   myDll.InvokeSomeSpecialMethod();
}

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

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

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

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

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

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

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

11 114

11 ответов:

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

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

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

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

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

" Какого черта я тестирую?"

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

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

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

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

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

// 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() метод в испытание. Счастливое поддержание.

когда вы пишете тесты, вы хотите проверить что, а не как. Смотрите Тестирование Черного Ящика дополнительные.

The что имя метода (или по крайней мере должно быть). Элемент как все мелкие детали реализации, которые живут внутри вашего метода. Хороший тесты позволяют поменять местами как без нарушения что.

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

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

если ответ да, то вы тестируете как, а не что.

чтобы ответить на ваш конкретный вопрос о тестирование кода с зависимостями файловой системы, допустим, у вас было что-то более интересное с файлом, и вы хотели сохранить содержимое в кодировке 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() метод, это было бы более целесообразно протестировать с интеграционного тестирования.

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

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

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

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

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

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

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

одним из способов было бы написать метод распаковки, чтобы принять InputStreams. Затем модульный тест может построить такой входной поток из массива байтов с помощью ByteArrayInputStream. Содержимое этого массива байтов может быть константой в коде модульного теста.

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

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

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

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

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

пока ваш проект правильно экспортируется / импортируется, чем ваш модульный тест должен работать.

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