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

140

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

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

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

Мне хочется написать код, который имеет дело непосредственно с файловой системой:

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);
}

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

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

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

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

Иуда Габриэль Химанго
источник
8
Люди говорят, что не пишите в файловую систему в модульном тесте, потому что, если у вас возникает соблазн записать в файловую систему, вы не понимаете, что составляет модульный тест. Модульный тест обычно взаимодействует с одним реальным объектом (тестируемым модулем), а все другие зависимости имитируются и передаются. Затем тестовый класс состоит из тестовых методов, которые проверяют логические пути через методы объекта и ТОЛЬКО логические пути в тестируемый блок.
Кристофер Перри,
1
в вашей ситуации единственная часть, которая нуждается в модульном тестировании, - это то, myDll.InvokeSomeSpecialMethod();где вы должны проверить, что он работает правильно как в успешных, так и в неудачных ситуациях, поэтому я бы не стал выполнять модульное тестирование, DoItно в нем DllRunner.Runговорится, что неправильное использование теста UNIT для двойной проверки того, что весь процесс работает допустимое неправильное использование, и поскольку это будет интеграционный тест, маскирующий модульное тестирование, обычные правила модульного тестирования не должны применяться так строго
MikeT 09

Ответы:

49

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

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

Адам Розенфилд
источник
27
+1, однако обратите внимание, что chdir()это касается всего процесса, поэтому вы можете лишиться возможности запускать свои тесты параллельно, если ваша тестовая среда или будущая версия ее поддерживает.
71

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

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

Андреас BuyKX
источник
13
Тестируемая DoItфункция, как указано, даже не требует тестирования. Как правильно заметил вопрошающий, не осталось ничего значимого для проверки. Теперь это реализация IZipper, IFileSystemи IDllRunnerчто нуждается в тестировании, но они самые вещи , которые были издевались за тест!
Ян Голдби
60

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

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

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

Когда вы пишете тесты, вы хотите проверить ЧТО, а не КАК . Подробнее см. Тестирование черного ящика .

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

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

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

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

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

Кристофер Перри
источник
1
Я не уверен, что согласен с вашим сообщением. Вы хотите сказать, что модульное тестирование такого метода не требуется? То есть вы говорите, что TDD - это плохо? Как если бы вы выполняли TDD, вы не можете написать этот метод, не написав сначала тест. Или вы полагаетесь на догадку, что ваш метод не потребует проверки? Причина, по которой ВСЕ фреймворки модульного тестирования включают функцию «проверки», заключается в том, что ее можно использовать. «Это плохо, потому что теперь вам нужно менять тест каждый раз, когда вы меняете детали реализации вашего метода» ... добро пожаловать в мир модульного тестирования.
Ронни
2
Вы должны тестировать КОНТРАКТ метода, а не его реализацию. Если вам приходится менять свой тест каждый раз, когда изменяется реализация этого контракта, тогда вам придется ужасно долго поддерживать как базу кода приложения, так и базу кода теста.
Кристофер Перри
1
@Ronnie слепое применение модульного тестирования бесполезно. Существуют проекты самого разного характера, и модульное тестирование не во всех из них эффективно. В качестве примера, я работаю над проектом , в котором 95% кода находится примерно на побочные эффекты (обратите внимание, это побочный эффект тяжелый характер является требованием , это важно сложность, не случайно , так как он собирает данные из широкий спектр источников с отслеживанием состояния и представляет его с минимальными манипуляциями, так что чистой логики практически нет). Модульное тестирование не эффективно здесь, тестирование интеграции.
Вики Чижвани
Побочные эффекты должны быть доведены до краев вашей системы, они не должны переплетаться по слоям. По краям вы тестируете побочные эффекты, то есть поведение. В любом другом месте вы должны стараться иметь чистые функции без побочных эффектов, которые легко тестировать и легко рассуждать, повторно использовать и составлять.
Кристофер Перри
24

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

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

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

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

Кент Бугарт
источник
5
Да, я тебя слышу. Вы попадаете в этот причудливый мир, в котором вы так сильно отделились, что все, что вам осталось, - это вызовы методов для абстрактных объектов. Воздушный пух. Когда вы дойдете до этой точки, вы не почувствуете, что действительно тестируете что-то реальное. Вы просто тестируете взаимодействие между классами.
Иуда Габриэль Химанго
7
Этот ответ ошибочен. Модульное тестирование - это не глазурь, это скорее сахар. Это запекается в пироге. Это часть написания кода ... деятельность по дизайну. Следовательно, вы никогда не «загрязняете» свой код чем-либо, что «облегчит тестирование», потому что тестирование - это то, что облегчает вам написание кода. В 99% случаев сложно написать тест, потому что разработчик написал код перед тестом и в итоге написал злой непроверенный код
Кристофер Перри
1
@Christopher: расширяя вашу аналогию, я не хочу, чтобы мой торт напоминал ванильный кусочек, чтобы я мог использовать сахар. Все, что я защищаю, - это прагматизм.
Kent Boogaart
1
@Christopher: твоя биография говорит сама за себя: «Я фанат TDD». Я же прагматичен. Я делаю TDD там, где он подходит, а не там, где нет - ничто в моем ответе не говорит о том, что я не использую TDD, хотя вы, кажется, так думаете. И независимо от того, TDD это или нет, я не буду вводить большие сложности ради облегчения тестирования.
Kent Boogaart
3
@ChristopherPerry Можете ли вы объяснить, как решить исходную проблему OP с помощью TDD? Я постоянно сталкиваюсь с этим; Мне нужно написать функцию, единственной целью которой является выполнение действия с внешней зависимостью, как в этом вопросе. Итак, даже в сценарии «сначала напиши тест», что бы это был за тест?
Dax Fohl
8

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

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

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

JC.
источник
Как часто они бегают, не имеет значения; мы используем сервер непрерывной интеграции, который автоматически запускает их за нас. Нам все равно, сколько времени они займут. Если «как долго запускать» не стоит беспокоиться, есть ли причина проводить различие между модульными и интеграционными тестами?
Иуда Габриэль Химанго,
4
На самом деле, нет. Но если разработчики хотят быстро запускать все модульные тесты локально, хорошо иметь простой способ сделать это.
JC.
6

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

nsayer
источник
Хорошо, это позволяет вводить поток. Внедрение зависимостей / IOC. Как насчет части разархивирования потока в файлы, загрузки DLL среди этих файлов и вызова метода в этой DLL?
Иуда Габриэль Химанго,
3

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

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

нажмите
источник
2

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

Солнечный Миленов
источник
Вы и nsayer, по сути, имеете одно и то же предложение: заставить мой код работать с потоками. Как насчет того, чтобы распаковать содержимое потока в файлы DLL, открыть эту DLL и вызвать в ней функцию? Что бы вы там делали?
Иуда Габриэль Химанго,
3
@JudahHimango. Эти части не обязательно могут быть проверены. Вы не можете все проверить. Выделите непроверенные компоненты в их собственные функциональные блоки и предположите, что они будут работать. Когда вы сталкиваетесь с ошибкой в ​​том, как работает этот блок, разработайте для этого тест и вуаля. Модульное тестирование НЕ означает, что вы должны тестировать все. В некоторых сценариях 100% покрытие кода нереально.
Зоран Павлович
1

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

Дрор Помощник
источник
1

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

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

Джеймс Андерсон
источник
0

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

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

Дэвид Сайкс
источник