Я пишу компонент, который, учитывая ZIP-файл, должен:
Я хочу провести модульное тестирование этого компонента.
У меня возникает соблазн написать код, который имеет дело непосредственно с файловой системой:
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 или собственный код.
в вашей ситуации единственной частью, которая требует модульного тестирования, будет myDll.InvokeSomeSpecialMethod();, где вы должны проверить, что он работает правильно как в успешных, так и в неудачных ситуациях, поэтому я не буду тестировать DoIt, но DllRunner.Run, который сказал, что неправильно использовал тест UNIT, чтобы дважды проверить, что весь процесс работает будет допустимым злоупотреблением, и, поскольку это будет интеграционный тест, маскирующий модульный тест, обычные правила модульного тестирования не должны применяться так строго





Один из способов - написать метод unzip для приема InputStreams. Затем модульный тест может построить такой InputStream из массива байтов с помощью ByteArrayInputStream. Содержимое этого байтового массива может быть константой в коде модульного теста.
Хорошо, это позволяет вводить поток. Внедрение зависимостей / IOC. Как насчет части разархивирования потока в файлы, загрузки DLL среди этих файлов и вызова метода в этой DLL?
Предполагая, что «взаимодействия с файловой системой» хорошо протестированы в самой структуре, создайте свой метод для работы с потоками и проверьте это. Открытие FileStream и передачу его методу можно исключить из ваших тестов, поскольку FileStream.Open хорошо протестирован создателями фреймворка.
У вас и nsayer, по сути, одно и то же предложение: заставить мой код работать с потоками. Как насчет того, чтобы распаковать содержимое потока в файлы dll, открыть эту dll и вызвать в ней функцию? Что бы вы там делали?
@JudahHimango. Эти части не обязательно могут быть проверены. Вы не можете все проверить. Разделите непроверенные компоненты на их собственные функциональные блоки и предположите, что они будут работать. Когда вы сталкиваетесь с ошибкой в работе этого блока, разработайте для нее тест и вуаля. Модульное тестирование НЕ означает, что вы должны тестировать все. 100% покрытие кода нереально в некоторых сценариях.
Я не хочу засорять свой код типами и концепциями, которые существуют только для облегчения модульного тестирования. Конечно, если это делает дизайн чище и лучше, то отлично, но я думаю, что часто это не так.
Я считаю, что ваши модульные тесты будут делать все, что в их силах, что может не обеспечивать 100% покрытие. Фактически, это может быть всего 10%. Дело в том, что ваши модульные тесты должны быть быстрыми и не иметь внешних зависимостей. Они могут проверять такие случаи, как «этот метод вызывает исключение ArgumentNullException, когда вы передаете значение null для этого параметра».
Затем я бы добавил интеграционные тесты (также автоматизированные и, вероятно, использующие ту же платформу модульного тестирования), которые могут иметь внешние зависимости и тестировать сквозные сценарии, подобные этим.
При измерении покрытия кода я измеряю как модульные, так и интеграционные тесты.
Да, я тебя слышу. Вы попадаете в этот причудливый мир, в котором вы так сильно разъединились, что все, что вам осталось, - это вызовы методов для абстрактных объектов. Воздушный пух. Когда вы доходите до этой точки, не возникает ощущения, что вы действительно тестируете что-то реальное. Вы просто тестируете взаимодействие между классами.
Это ошибочный ответ. Модульное тестирование - это не глазурь, это скорее сахар. Это запекается в пироге. Это часть написания кода ... деятельность по дизайну. Следовательно, вы никогда не «загрязняете» свой код чем-либо, что «облегчит тестирование», потому что тестирование - это то, что облегчает вам написание кода. В 99% случаев тест сложно написать, потому что разработчик написал код перед тестом, а в итоге написал злой непроверяемый код
@Christopher: продолжая аналогию, я не хочу, чтобы мой торт напоминал ванильный кусочек, чтобы я мог использовать сахар. Все, что я защищаю, - это прагматизм.
@Kent Гораздо более прагматично разрабатывать программное обеспечение, которое вы пишете, используя TDD в качестве процесса. Вы представляете ложную аналогию, потому что написание тестируемого кода не имеет ничего общего с написанием тестового «ароматизированного» кода.
@Christopher: твоя биография говорит сама за себя: «Я фанат TDD». Я же прагматичен. Я использую TDD там, где он подходит, а не там, где нет - ничто в моем ответе не говорит о том, что я не использую TDD, хотя вы, кажется, так думаете. И неважно, TDD это или нет, я не буду вводить большие сложности ради облегчения тестирования.
@KentBoogaart Скажите, а где TDD не подходит? И как TDD добавляет сложности? TDD упрощает код, поэтому, если мы говорим о прагматизме, я бы сказал, что тестирование вашего кода - более прагматичный выбор. Вы снова используете фразу «облегчить тестирование». TDD не облегчает тестирование, он способствует написанию надежного и чистого кода.
@ChristopherPerry Можете ли вы объяснить, как решить исходную проблему OP с помощью TDD? Я все время сталкиваюсь с этим; Мне нужно написать функцию, единственной целью которой является выполнение действия с внешней зависимостью, как в этом вопросе. Итак, каким будет этот тест даже в сценарии «сначала напиши тест»?
@DaxFohl Я могу говорить на эту тему вечно. Я добавил ответ на вопрос OP, взгляните на него.
Вы не должны тестировать взаимодействие классов и вызов функций. вместо этого вам следует рассмотреть возможность интеграционного тестирования. Протестируйте требуемый результат, а не операцию загрузки файла.
Нет ничего плохого в том, чтобы поразить файловую систему, просто считайте это интеграционным тестом, а не модульным тестом. Я бы заменил жестко запрограммированный путь относительным путем и создал подпапку TestData, содержащую zip-архивы для модульных тестов.
Если интеграционные тесты выполняются слишком долго, разделите их, чтобы они выполнялись не так часто, как ваши быстрые модульные тесты.
Я согласен, иногда мне кажется, что тестирование, основанное на взаимодействии, может вызвать слишком сильную взаимосвязь и часто заканчивается недостаточной ценностью. Вы действительно хотите протестировать распаковку файла здесь, а не просто проверить, что вы вызываете правильные методы.
То, как часто они бегают, не имеет большого значения; мы используем сервер непрерывной интеграции, который автоматически запускает их за нас. Нам все равно, сколько времени они займут. Если «как долго работать» не стоит беспокоиться, есть ли причина проводить различие между модульными и интеграционными тестами?
Не совсем. Но если разработчики хотят быстро запустить все модульные тесты локально, хорошо иметь простой способ сделать это.
Это больше похоже на интеграционный тест, поскольку вы зависите от конкретной детали (файловой системы), которая теоретически может измениться.
Я бы выделил код, связанный с ОС, в отдельный модуль (класс, сборку, банку и т. д.). В вашем случае вы хотите загрузить конкретную DLL, если она найдена, поэтому создайте интерфейс IDllLoader и класс DllLoader. Попросите ваше приложение получить DLL из DllLoader с помощью интерфейса и проверить, что ... вы не несете ответственности за распакованный код в конце концов, верно?
В этом нет ничего плохого, вопрос лишь в том, назовете ли вы это модульным тестом или интеграционным тестом. Вам просто нужно убедиться, что при взаимодействии с файловой системой не возникнет непредвиденных побочных эффектов. В частности, убедитесь, что вы выполняете очистку после себя - удаляете все созданные вами временные файлы - и что вы случайно не перезаписываете существующий файл, имя которого совпадает с именем временного файла, который вы использовали. Всегда используйте относительные пути, а не абсолютные пути.
Также было бы неплохо поместить chdir() во временный каталог перед запуском теста, а затем обратно chdir().
+1, однако обратите внимание, что chdir() распространяется на весь процесс, поэтому вы можете нарушить возможность параллельного запуска тестов, если ваша тестовая среда или будущая версия ее поддерживает.
Как уже говорили другие, первый подходит в качестве интеграционного теста. Второй тестирует только то, что функция должна делать на самом деле, а это все, что должен делать модульный тест.
Как показано, второй пример выглядит немного бессмысленным, но он дает вам возможность проверить, как функция реагирует на ошибки на любом из шагов. У вас нет проверки ошибок в примере, но в реальной системе у вас может быть, и внедрение зависимостей позволит вам проверить все ответы на любые ошибки. Тогда цена окупится.
Для модульного теста я бы посоветовал вам включить тестовый файл в свой проект (файл 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, но это как раз то, что было смоделировано для теста!
Ваш вопрос раскрывает одну из самых сложных частей тестирования для разработчиков, которые только начинают это делать:
"Что, черт возьми, я тестирую?"
Ваш пример не очень интересен, потому что он просто склеивает некоторые вызовы 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 слепое применение модульного тестирования бесполезно. Существуют проекты самого разного характера, и модульное тестирование не во всех из них эффективно. В качестве примера я работаю над проектом, в котором 95% кода - это о - побочные эффекты (обратите внимание, эта природа с тяжелыми побочными эффектами - это по требованию, это существенная сложность, а не случайность, поскольку он собирает данные из самых разных состояний с отслеживанием состояния). исходники и представляет его с очень небольшими манипуляциями, так что нет чистой логики). Здесь действует модульное тестирование нет, интеграционное тестирование.
Побочные эффекты должны быть доведены до краев вашей системы, они не должны переплетаться по слоям. По краям вы тестируете побочные эффекты, то есть поведения. В любом другом месте вы должны стараться иметь чистые функции без побочных эффектов, которые легко тестировать и легко рассуждать, повторно использовать и составлять.
Люди говорят, что не пишите в файловую систему в модульном тесте, потому что, если у вас возникает соблазн записать в файловую систему, вы не понимаете, что представляет собой модульный тест. Модульный тест обычно взаимодействует с одним объектом настоящий (тестируемым модулем), а все другие зависимости имитируются и передаются. Затем тестовый класс состоит из тестовых методов, которые проверяют логические пути через методы объекта и ТОЛЬКО логические пути в тестируемый блок.