This article is a revised version. It was previously published at refactoring-legacy-code.net published.
This is a series of articles. You can find the other articles here:
- The tricky cases in testing - GUI
- The tricky cases in testing - Events
- The tricky cases in testing - exceptions
- The tricky cases in testing - Visibility
Accessing resources such as reading or writing files or accessing databases often causes problems in unit tests. This is mainly due to the fact that access to a resource, such as a file or database, is mixed with other aspects in the code. Within a method, both the resource is accessed and the read results are used. Mixing the aspects makes automated testing more difficult. But before I come to these difficult cases, I will first deal with pure resource access.
Automatically test the reading of a file with a unit test
To ensure a clear separation of aspects, resource accesses must always be separated from the processing of read data. In the following example, a class FileAdapter is responsible for reading and writing strings to a file.
using System.Collections.Generic; using System.IO; namespace resources { public class FileAdapter { public IEnumerable Read(string filename) { return File.ReadAllLines(filename); } public void Write(string filename, IEnumerable content) { File.WriteAllLines(filename, content); } } }
The method Read is called with a file name and returns the contents of the file as an enumeration of strings. Testing this automatically is not a problem, as the following listing shows.
using NUnit.Framework; namespace resources.tests { [TestFixture] public class FileAdapterReadTests { private FileAdapter sut; [SetUp] public void Setup() { Directory.SetCurrentDirectory( TestContext.CurrentContext.TestDirectory); sut = new FileAdapter(); } [Test] public void Read_Existing_File() { var lines = sut.Read("testdaten\\textdatei.txt"); Assert.That(lines, Is.EqualTo(new[]{"z1", "z2", "z3"})); } } }
An instance of the class FileAdapter is created in the test setup and entered in the suthow System under teststored. The instance can then be accessed in the test. The Read method and a subsequent comparison of the result with the expected strings.
Integrating files into the test project
The real challenge here is to reliably provide the file for the unit test. An absolute path is out of the question. This may be accessible on the developer's computer, but the Continuous Integration server, this test will fail because the file is missing. However, unit tests must be executable on every computer after checking out of version control.
In the Read call, the file name "testdata\textfile.txt" is used. The path to the file is specified in relative terms. This means that the Read method searches for the file starting from the directory that is the current directory during the execution of the test. For NUnit 2.x, this was the directory into which Visual Studio translated, i.e. the Output Directory (e.g. "bin\Debug").
Unfortunately, this behavior has been changed with NUnit 3.x. Therefore, the current directory must first be set to the execution directory. When using NUnit 3.x, add the following as shown above in the Setup method the line
Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory);
This line sets the current directory to the directory in which the assembly containing the test that is currently being executed is located.
The file "textfile.txt" in the test project in a subdirectory "test data" filed. I have also added the attribute Copy to Output Directory set to the value Copy if newer. This ensures that Visual Studio saves the file including the directory structure in the "bin\Debug" directory is created or updated. It can therefore be opened in the test with a relative path.
Test the writing of a file automatically
The test for writing proceeds as follows: In the test, a file is created by calling the method to be tested Write is generated. The file generated in this way is then compared with a file containing the expected content. The following listing shows the automated test.
using NUnit.Framework; namespace resources.tests { [TestFixture] public class FileAdapterWriteTests { private FileAdapter sut; [SetUp] public void Setup() { Directory.SetCurrentDirectory( TestContext.CurrentContext.TestDirectory); sut = new FileAdapter(); } [Test] public void Write_a_file() { sut.Write("data.txt", new[] {"a", "b", "c"}); FileAssert.AreEqual("testdaten\\writeTest.txt", "daten.txt"); } } }
In the test, calling up the Write method the file "data.txt" is generated. This is then compared with the file "testdaten\writeTest.txt". I perform the comparison of the file content with NUnits FileAssert through. Unfortunately, there is no counterpart to this functionality in MSTest. Another reason to switch away from MSTest.
Automated testing of integrated resource access
In the following example, I have combined the resource access with the processing of the read data in one method.
using System.Collections.Generic; using System.IO; namespace resources { public class CsvReader { public IEnumerable Read(string filename) { var lines = File.ReadAllLines(filename); foreach (var line in lines) { var fields = line.Split(';'); yield return fields; } } } }
The Read method reads by means of File.ReadAllLines from a file and also processes the read content. So two aspects are mixed here.
To test this class, I created a file "testdata\somerecords.csv", which is accessed in the test. This is a Integration testbecause the two aspects Resource access and Editing the read data in the method Read and are also tested together in the following example.
using System.IO; using System.Linq; using NUnit.Framework; namespace resources.tests { [TestFixture] public class CsvReaderTests { [SetUp] public void Setup() { Directory.SetCurrentDirectory(TestContext.CurrentContext.TestDirectory); } [Test] public void CSV_Records_are_read_correctly() { var sut = new CsvReader(); var records = sut.Read("testdaten\\somerecords.csv").ToArray(); Assert.That(records[0], Is.EqualTo( new[] {"Title", "Price", "Author"})); Assert.That(records[1], Is.EqualTo( new[] { "Working Effectively with Legacy Code", "20", "Michael Feathers"})); } } }
With mocks to unit tests
The logical part of the method Read cannot be tested in isolation with unit tests, as reading from the file is combined with the logic. It is therefore an integration test. Here again TypeMock isolator. With this powerful mock framework, the call of File.ReadAllLines can be influenced in the test. In the test, a lambda expression specified in the test method is executed instead of calling the .NET Framework method. This returns a predefined enumeration of strings so that the logic part of the method is tested with these strings as input.
[Test, Isolated] public void Isolated_logic_test() { var sut = new CsvReader(); Isolate .WhenCalled(() => File.ReadAllLines("")) .WillReturn(new[] { "a;b;c", "1;2;3" }); var records = sut.Read("testdaten\\somerecords.csv").ToArray(); Assert.That(records[0], Is.EqualTo(new[] { "a", "b", "c" })); Assert.That(records[1], Is.EqualTo(new[] { "1", "2", "3" })); }
The use of TypeMock isolator or comparable tools should not be regarded as a free pass. Nevertheless, the principles of Clean Code Developer initiative. Although this tool can be used to create testability, it does not improve changeability one bit.
If, for example, the program is to be changed so that the data is no longer read from a file but retrieved from a web service instead, this is difficult to achieve in the structure shown. It would then make sense to first change the File.ReadAllLines call from the method before it is replaced by a web service call.
Conclusion
Despite powerful tools, there is no way around designing and adhering to principles. However, these serve to put legacy code under test and thus create the prerequisites for refactorings. Powerful tools are useful and fulfill their purpose. On the other hand, the handling and sensible use of the tool must be learned so as not to cause any damage.
What is your experience of using powerful mock frameworks in unit tests? Write a comment!