В большинстве своих проектов я обычно создаю статический класс, содержащий все функции, которые я использую в своем проекте. Но когда дело доходит до модульного тестирования, я чувствую, что в моем дизайне есть большой недостаток, поскольку я не могу использовать Moq для имитации этого статического класса.
Я могу привести простой пример одного проекта, над которым я работаю, который копирует много файлов, поэтому я сделал небольшой помощник, чтобы избежать избыточности в моем коде.
public static class Helpers
{
public static void CopyFile(string sourcePath, string destPath)
{
Log.Verbose("Start copy from {SourcePath} to {DestPath}", sourcePath, destPath);
if (!Directory.Exists(Path.GetDirectoryName(destPath)))
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath));
}
File.Copy(sourcePath, destPath, true);
Log.Verbose("End of copy from {SourcePath} to {DestPath}", sourcePath, destPath);
}
}
Таким образом, любой код, вызывающий эту функцию, не подлежит тестированию, поскольку я не могу имитировать статическую функцию внутри статического класса, который также использует System.IO.
Я попытался использовать System.IO.Abstraction и внедрение зависимостей, чтобы решить эту проблему, и тогда у меня есть следующий код:
public interface IHelpers
{
public void CopyFile(string sourcePath, string destPath);
}
public class Helpers(IFileSystem fileSystem) : IHelpers
{
private readonly IFileSystem FileSystem = fileSystem;
public void CopyFile(string sourcePath, string destPath)
{
Log.Verbose("Start copy from {SourcePath} to {DestPath}", sourcePath, destPath);
if (!FileSystem.Directory.Exists(Path.GetDirectoryName(destPath)))
{
FileSystem.Directory.CreateDirectory(Path.GetDirectoryName(destPath));
}
FileSystem.File.Copy(sourcePath, destPath, true);
Log.Verbose("End of copy from {SourcePath} to {DestPath}", sourcePath, destPath);
}
}
Теперь мне просто нужно внедрить свой IHelper туда, куда мне нужно вызвать CopyFile, и все кажется хорошо. За исключением того, что у меня есть редкий случай, когда кажется неправильным внедрять этого помощника внутри класса.
public class Component
{
public string Name;
public string Path;
public void ExportOutput()
{
// ...
// much calculation then...
// multiple calls to Helpers.CopyFile(src,dest);
// ...
}
}
Чтобы решить эту проблему, я передал класс IHelper в качестве параметра ExportOutput, но это кажется совершенно отвратительным.
Единственное решение, которое я нашел для этой проблемы, — это удалить функцию ExportOutput и поместить ее туда, где я могу полагаться на внедрение зависимостей для использования класса Helper.
Это правильно или я совсем упускаю суть?
Как вы обычно обрабатываете эти небольшие вспомогательные функции в своем коде?
Класс компонента — это модель данных, с которыми я работаю. Мне кажется странным использовать внедрение зависимостей в этом классе, поскольку я чаще всего создаю его экземпляр следующим образом: ComponentComponent = new();
ну, с DI-контейнером вам почти никогда не следует ничего делать, особенно когда эти вещи вводят зависимости вне вашего контроля.
Если вы хотите имитировать операции с файловой системой, вам понадобится интерфейс, который абстрагирует файловую систему и внедряет его везде, где используется файловая система.
Альтернативой вашему подходу было бы создание вашего CopyFile
как расширения файловой системы, т.е.
public static class Helpers
{
public static void CopyFile(this IFileSystem fileSystem, string sourcePath, string destPath)
{
...
}
}
При таком подходе реализация CopyFile
будет рассматриваться как часть тестируемой вещи, и это может быть желательным или нежелательным в зависимости от обстоятельств. Возможным преимуществом является то, что это позволит тестируемому методу предоставить собственную реализацию CopyFile без необходимости изменения зависимостей или тестов.
За исключением того, что у меня есть редкий случай, когда кажется неправильным внедрять этого помощника внутри класса.
Если ваш метод ExportOutput
зависит от файловой системы и вы хотите, чтобы эту зависимость можно было тестировать, вам нужно будет каким-то образом внедрить эту зависимость. Я бы, вероятно, использовал внедрить зависимость в конструктор Component
или переместил ExportOutput
в отдельный класс. Но могут быть случаи, когда использование параметра метода может быть уместным.
Обратите внимание, что файловые системы могут иметь ряд особенностей, которые сложно воспроизвести точно, особенно если учесть, что операции ввода-вывода могут не работать. Поэтому вы можете захотеть провести хотя бы несколько тестов, использующих реальную файловую систему, даже если это может быть значительно медленнее. Просто не забудьте после этого удалить все оставшиеся файлы.
Я думаю, что уровень абстракции может помочь этому выглядеть аккуратнее, отделив функциональность копирования.
public interface IFileCopier
{
public void CopyFile(string sourcePath, string destPath);
}
public class FileCopier:IFileCopier
{
private readonly IFileSystem FileSystem;
public void CopyFile(string sourcePath, string destPath)
{
Log.Verbose("Start copy from {SourcePath} to {DestPath}", sourcePath, destPath);
if (!FileSystem.Directory.Exists(Path.GetDirectoryName(destPath)))
{
FileSystem.Directory.CreateDirectory(Path.GetDirectoryName(destPath));
}
FileSystem.File.Copy(sourcePath, destPath, true);
Log.Verbose("End of copy from {SourcePath} to {DestPath}", sourcePath, destPath);
}
}
public class Helpers
{
private readonly IFileCopier FileCopier;
public void CopyFile(string sourcePath, string destPath)
{
FileCopier.CopyFile(sourcePath,destPath);
}
}
Затем вы можете перейти к макетированию или внедрению зависимости в экземпляр IFileCopier
.
Это может показаться простым переименованием (поскольку Helper
пока не имеет других функций), но оно подчеркивает, какой функционал вы хотите внедрить и почему.
Как уже отмечалось, вы можете использовать интерфейс IFileSystem именно для этого сценария.
В противном случае, если метод, с которым вы работаете, является статическим, вам не нужно создавать интерфейс. Вы можете использовать делегата. Таким образом, вам не придется выбирать между статическим методом или внедрением зависимостей. Вы можете внедрить статический метод.
delegate void CopyFunction(string sourcePath, string destinationPath);
Вы можете зарегистрировать его с помощью DI так же, как и интерфейс или класс:
services.AddSingleton<CopyFunction>(Helpers.CopyFile);
...а затем ввести его.
public class Component
{
private readonly CopyFunction _copyFunction;
public Component(CopyFunction copyFunction)
{
_copyFunction = copyFunction;
}
}
Вы вызываете ее так же, как и любую другую функцию.
_copyFunction(source, destination);
Что особенно приятно, для того, чтобы поглумиться, не нужен Moq. Вы можете использовать анонимную функцию.
Возможно
CopyFunction copyFunction = (source, destination) => {}; // does nothing
Или, если вы хотите подтвердить, что были переданы определенные значения, вы можете сделать что-то вроде этого:
string copiedSource = null;
string copiedDestination = null;
CopyFunction copyFunction = (source, destination) =>
{
copiedSource = source;
copiedDestination = destination;
};
... чтобы после вызова функции вы могли утвердить значения copiedSource
и copiedDestination
.
Это преимущество делегатов. Их очень легко высмеять, создав анонимный метод.
Даже если позже код изменится и вы захотите заменить статический метод методом экземпляра, вы можете это сделать без замены делегата на интерфейс и изменения всего кода.
Еще одним преимуществом делегатов является то, что по определению они преследуют единственную цель. Они представляют собой один метод. Они могут быть защитой от тенденции создавать большие интерфейсы со слишком большим количеством методов. Вы можете добавить метод (или 20) к интерфейсу, но не к делегату.
Вы также можете использовать Action<string, string>
вместо делегата. Риск, хотя и маловероятный, заключается в том, что вы захотите внедрить в два места два метода, которые имеют одну и ту же сигнатуру, но делают разные вещи. Делегат позволяет вам указать использование внедряемого метода.
Разве вы не можете внедрить хелпер в свой класс вместо
ExportOutput
-функции? И все же, почему вы находите это «отвратительным»? Это более или менее то, чего я ожидал.