This sounds like an integration test but, since textToPdf is just one function, I think it classifies more as a Unit test (where the unit of code under test is that single function).
The mark of a unit test is not "just one function". The mark of a unit test that there is only one component under test (i.e. real, not mocked, and the focus of the test), but it's perfectly possible for a single public behavior to call several functions.
This is an integration test because you're using more than one real component: your class and the file system.
It also seems like, in order to test this function, the PDF file would need to be saved to the user's disk.
Answers like this one always recommend "mocking the file system", but I am not sure what that means.
We can write a lot of layers in our logic. There is no practical upper limit on that. However, no matter how many layers we write, there's always one layer on the outside, which has to interact with the real world (e.g. a monitor, network, OS, file system, IO device, ...)
It is the nature of the beast that the last layer can never truly be unit tested, because it inherently relies on an external resource. All of the inside layers can be tested, because their neighboring layer can be mocked.
You want to unit test this class. This implies that there is business logic in the class which you want to run your test on. But the class also contains direct dependency on an external resource, in this case the file system, making it impossible to unit test the class.
Let's use this example:
public class TextToPdf
{
public JobResult DoJob()
{
// Read from disk
var input = File.ReadAllLines(@"C:\input.txt");
// Complicated business logic that you want to test
var result = ...;
// Write to disk
File.Write(@"C:\output.pdf", result);
return new SuccessJobResult(result);
}
}
The solution is to separate the business logic from the external resource handling logic. In essence, you're separating this layer into two layers: the business logic and the file system interaction.
public interface IFileReader
{
IEnumerable<string> Read(string path);
}
public class FileReader : IFileReader
{
public IEnumerable<string> Read(string path)
{
return File.ReadAllLines(path);
}
}
public interface IFileWriter
{
void Write(string path, byte[] data);
}
public class FileWriter : IFileWriter
{
public void Write(string path, byte[] data)
{
File.Write(path, data);
}
}
This is now the outer layer. Now we can change our original class to depend on this outer layer:
public class TextToPdf
{
public TextToPdf(IFileReader reader, IFileWriter writer)
{
this.reader = reader;
this.writer = writer;
}
private readonly IFileReader reader;
private readonly IFileWriter writer;
public JobResult DoJob()
{
// Read from disk
var input = this.reader.Read(@"C:\input.txt");
// Complicated business logic that you want to test
var result = ...;
// Write to disk
this.writer.Write(@"C:\output.pdf", result);
return new SuccessJobResult(result);
}
}
Now, the file system handling is the outer layer, and the business logic is another one of those inside layers, which we can now test by mocking its neighbor (the file system handling).
Notice that this class takes in a IFileWriter
and IFileReader
interface. We could pass in the real classes that actually use the file system, but we could also design classes that implement the same interface but don't actually use the file system.
In a unit test, we're going to use fake classes so that we don't actually use the file system.
public class FakeFileReader : IFileReader
{
public IEnumerable<string> Read(string path)
{
return new List<string>()
{
"This is just some text",
"which doesn't come from a file",
"but the caller doesn't know that!"
};
}
}
public class FakeFileWriter : IFileWriter
{
public void Write(string path, byte[] data)
{
// Let's just not do anything
}
}
Now you can unit test your TextToPdf
class without touching the actual file system:
var mockedReader = new FakeFileReader();
var mockedWriter = new FakeFileWriter();
var unit = new TextToPdf(mockedReader, mockedWriter);
var result = unit.DoJob();
// Assert that result matches the expected output
And that's it.
This is a very simplified example. In general, your mocked classes should be set up in a way that the test can define the return values (e.g. the text that would allegedly be read from a file), so that the test can decide what fake values should be used.
You might wonder how you could now write unit tests for FileReader
and FileWriter
. The short answer is you can't. You can only integration test them.
If these classes still contain some business logic that you want to unit test, then you have to repeat the process. Split the business logic from the interaction with the real external resource. This kind of abstraction ensures that you can mock the connection between the business logic and the external resource.