TestableIO / System.IO.Abstractions

Just like System.Web.Abstractions, but for System.IO. Yay for testable IO access!
http://www.nuget.org/packages/System.IO.Abstractions
MIT License
1.53k stars 256 forks source link

Be able to mock temporary directories #1154

Closed romainf-ubi closed 1 month ago

romainf-ubi commented 1 month ago

Is your feature request related to a problem? Please describe. I can't mock methods that depends on Directory.CreateTempSubdirectory().

Describe the solution you'd like I'm fairly new using this package, but the idea would be to predictably know what the next calls to CreateTempSubdirectory() will return.

Describe alternatives you've considered Instead of creating the temporary directory within the method, I could pass it as a parameter. The issue is that the method implements an interface that I can't modify to add the new parameter. I could add a property to the class, but then the dependency injection model will become weird as not everything will be set by the constructor.

Maybe there's a way to solve this by combining MockFileSystem and Mock<IFileSystem>?

Additional context Imagine this class (I can't change Execute signature):

class Job : IJob
{
  private readonly IFileSystem _fileSystem;

  public Job(IFileSystem fileSystem)
  {
    _fileSystem = fileSystem;
  }

  void Execute()
  {
    var tempDir = _fileSystem.Directory.CreateTempSubdirectory();

    _fileSystem.File.Create(_fileSystem.Path.Join(tempDir.FullName, "file.txt"));
  }
}

class JobTests
{
  public TestExecute()
  {
    // Arrange
    var mockFileSystem = new MockFileSystem();

    // ISSUE:
    // Usually, I would mock the files using MockFileSystem.AddFile(), but since I
    // don't know what will be the value returned by CreateTempSubdirectory()
    // in Job.Execute(), how can I do that?

    var sut = new Job(mockFileSystem);

    // Act
    sut.Execute();

    // Assert
    // I want to assert that the Job created the files, but I don't see how?
  }
}

It's possible that I'm doing it wrong, so if you have any insights, I'd be glad to hear them 😉

vbreuss commented 1 month ago

There is no built-in way to manipulate how the name of the created subdirectory is created and changing the signature is out of the question, as the maxime of this project is to map the the public interface of the .NET System.IO API as close as possible. However, maybe you could work around this issue as in the following example:

    [Fact]
    public void GetCreatedTempSubdirectory()
    {
        var fileSystem = new MockFileSystem();

        // void Execute()
        var tempDir = fileSystem.Directory.CreateTempSubdirectory();
        fileSystem.File.Create(fileSystem.Path.Join(tempDir.FullName, "file.txt"));

        var result = fileSystem.Directory.GetDirectories(
            fileSystem.Path.GetTempPath()).Single();

        result.Should().Be(tempDir.FullName);
    }

As the MockFileSystem starts with an empty temporary directory, your generated directory will be the only directory in the temp path and it should not be hard to get its path (or file name) in the test.

Would this work for you, @romainf-ubi ?

romainf-ubi commented 1 month ago

Thanks for your quick answer @vbreuss!

Don't worry, I fully understand that you don't want to change the signature of the system methods, and rest assure that it's not what I'm proposing 😄 Actually I'm more explaining my issue than proposing anything, but I hope that this discussion may lead to a nice solution.

My issue is only on the mock/fake testing side, where I need to know where the next temporary directory will be created before calling the method I'm testing (i.e. sut.Execute()).

I'll test your solution and I'll get back to you asap.

romainf-ubi commented 1 month ago

So it took me some time to understand how your workaround would fit my requirements, but I eventually got to change my mind around and find a proper solution!

The issue was that I was just thinking about mocking services (and not about faking them). So, in the mocking mindset, I wanted to prepare all the dependencies calls so that the calling method can run. But in the faking mindset, what I really needed was to fake creating the file when the dependent service required it.

So, in more concrete terms, I mocked the dependent service so that it will create a fake file when requested, like so:

public TestExecute()
{
  // Arrange
  var mockFileSystem = new MockFileSystem();
  var downloadServiceMock = new Mock<IDownloadService>();
  var sut = new Job(mockFileSystem.Object, downloadServiceMock.Object);

  // Create the fake file when DownloadFileAsync() is called.
  downloadServiceMock
    .Setup(x => x.DownloadFileAsync(It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
    .Callback((Uri _, string filePath, CancellationToken _) => fakeFileSystem.File.Create(filePath));

  // Act
  sut.Execute();

  // Assert
  downloadServiceMock.Verify(
    x => x.DownloadFileAsync(It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
    Times.Once());
}

In the end, I don't care about the temporary directory path as I only care that the whole Execute() method ran as expected.

I'll close this issue, thanks for the help, and great work on this project, it really works well!

vbreuss commented 1 month ago

I am glad you found a solution 👍