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

Я пишу компонент, который, учитывая 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-файл и т. д.

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

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

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

Люди говорят, что не пишите в файловую систему в модульном тесте, потому что, если у вас возникает соблазн записать в файловую систему, вы не понимаете, что представляет собой модульный тест. Модульный тест обычно взаимодействует с одним объектом настоящий (тестируемым модулем), а все другие зависимости имитируются и передаются. Затем тестовый класс состоит из тестовых методов, которые проверяют логические пути через методы объекта и ТОЛЬКО логические пути в тестируемый блок.

Christopher Perry 24.10.2013 04:59

в вашей ситуации единственной частью, которая требует модульного тестирования, будет myDll.InvokeSomeSpecialMethod();, где вы должны проверить, что он работает правильно как в успешных, так и в неудачных ситуациях, поэтому я не буду тестировать DoIt, но DllRunner.Run, который сказал, что неправильно использовал тест UNIT, чтобы дважды проверить, что весь процесс работает будет допустимым злоупотреблением, и, поскольку это будет интеграционный тест, маскирующий модульный тест, обычные правила модульного тестирования не должны применяться так строго

MikeT 09.04.2018 15:46
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
141
2
47 032
11
Перейти к ответу Данный вопрос помечен как решенный

Ответы 11

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

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

Judah Gabriel Himango 24.09.2008 23:57

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

У вас и nsayer, по сути, одно и то же предложение: заставить мой код работать с потоками. Как насчет того, чтобы распаковать содержимое потока в файлы dll, открыть эту dll и вызвать в ней функцию? Что бы вы там делали?

Judah Gabriel Himango 25.09.2008 00:01

@JudahHimango. Эти части не обязательно могут быть проверены. Вы не можете все проверить. Разделите непроверенные компоненты на их собственные функциональные блоки и предположите, что они будут работать. Когда вы сталкиваетесь с ошибкой в ​​работе этого блока, разработайте для нее тест и вуаля. Модульное тестирование НЕ означает, что вы должны тестировать все. 100% покрытие кода нереально в некоторых сценариях.

Zoran Pavlovic 26.10.2012 18:55

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

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

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

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

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

Judah Gabriel Himango 25.09.2008 00:23

Это ошибочный ответ. Модульное тестирование - это не глазурь, это скорее сахар. Это запекается в пироге. Это часть написания кода ... деятельность по дизайну. Следовательно, вы никогда не «загрязняете» свой код чем-либо, что «облегчит тестирование», потому что тестирование - это то, что облегчает вам написание кода. В 99% случаев тест сложно написать, потому что разработчик написал код перед тестом, а в итоге написал злой непроверяемый код

Christopher Perry 24.10.2013 04:56

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

Kent Boogaart 24.10.2013 06:59

@Kent Гораздо более прагматично разрабатывать программное обеспечение, которое вы пишете, используя TDD в качестве процесса. Вы представляете ложную аналогию, потому что написание тестируемого кода не имеет ничего общего с написанием тестового «ароматизированного» кода.

Christopher Perry 24.10.2013 09:12

@Christopher: твоя биография говорит сама за себя: «Я фанат TDD». Я же прагматичен. Я использую TDD там, где он подходит, а не там, где нет - ничто в моем ответе не говорит о том, что я не использую TDD, хотя вы, кажется, так думаете. И неважно, TDD это или нет, я не буду вводить большие сложности ради облегчения тестирования.

Kent Boogaart 24.10.2013 10:16

@KentBoogaart Скажите, а где TDD не подходит? И как TDD добавляет сложности? TDD упрощает код, поэтому, если мы говорим о прагматизме, я бы сказал, что тестирование вашего кода - более прагматичный выбор. Вы снова используете фразу «облегчить тестирование». TDD не облегчает тестирование, он способствует написанию надежного и чистого кода.

Christopher Perry 24.10.2013 11:29

@ChristopherPerry Можете ли вы объяснить, как решить исходную проблему OP с помощью TDD? Я все время сталкиваюсь с этим; Мне нужно написать функцию, единственной целью которой является выполнение действия с внешней зависимостью, как в этом вопросе. Итак, каким будет этот тест даже в сценарии «сначала напиши тест»?

Dax Fohl 17.04.2014 05:20

@DaxFohl Я могу говорить на эту тему вечно. Я добавил ответ на вопрос OP, взгляните на него.

Christopher Perry 17.04.2014 08:54

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

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

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

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

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

Judah Gabriel Himango 25.09.2008 00:07

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

JC. 25.09.2008 00:40

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

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

Ответ принят как подходящий

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

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

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

user23743 12.10.2010 17:12

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

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

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

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

Yay! Now it's testable; I can feed in test doubles (mocks) to the DoIt method. But at what cost? I've now had to define 3 new interfaces just to make this testable. And what, exactly, am I testing? I'm testing that my DoIt function properly interacts with its dependencies. It doesn't test that the zip file was unzipped properly, etc.

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

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

Ian Goldby 21.02.2014 19:02

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

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

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

Я не уверен, что согласен с вашим сообщением здесь. Вы хотите сказать, что модульное тестирование такого метода не требуется? То есть вы в основном говорите, что TDD - это плохо? Как если бы вы выполняли TDD, вы не можете написать этот метод, не написав предварительно тест. Или вы полагаетесь на догадку, что ваш метод не потребует проверки? Причина, по которой ВСЕ фреймворки модульного тестирования включают функцию «проверки», заключается в том, что ее можно использовать. «Это плохо, потому что теперь вам нужно менять тест каждый раз, когда вы меняете детали реализации вашего метода» ... добро пожаловать в мир модульного тестирования.

Ronnie 12.11.2015 18:28

Вы должны тестировать КОНТРАКТ метода, а не его реализацию. Если вам приходится менять свой тест каждый раз, когда изменяется реализация этого контракта, тогда вам придется ужасно долго поддерживать как базу кода приложения, так и базу кода теста.

Christopher Perry 21.12.2016 05:19

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

Vicky Chijwani 07.02.2017 20:36

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

Christopher Perry 20.12.2017 22:03

Другие вопросы по теме