17.2 Test your file-based application without the file system
◆ Problem
You have an application that interacts with the file system and you want to test it without involving the file system.
◆ Background
It is a common question: “I have a class that reads data from a file. How do I test it?”
The straightforward answer is to read the data from the file and verify it against the data you expect. The bad news is that in applications not designed to be tested, this approach has some serious disadvantages. First, it is common to see the file name hard coded in the class. A simple test involves putting known data in a file, pointing the file-reading object at the file, and then verifying the data it reads. If the test can- not tell the file-reading object which file to use, then there is no way to write this test without disturbing the production application we are trying to test. Next, it is com- mon to see one class with two responsibilities: reading raw text from the file and parsing that text into objects. Remember the Single Responsibility Principle?1
◆ Recipe
The file system is yet another expensive, external resource. As an external resource, it is sensitive to changes occurring outside your Java application and its tests, so you want to depend on it as little as possible. It is fortunate, then, that the Java class libraries were designed with this in mind: its I/O libraries make it easy to separate the act of reading and writing data from the data sources. The data might come from a file, a network connection, another Java Virtual Machine, or even just a String in memory. The key to testing file-based components is to sepa- rate them from the file system as much as possible. Make your integration to the file system as thin as possible.
1 J. B., in particular, finds it difficult to do more than one thing at a time, which is one reason he is so keen on respecting the Single Responsibility Principle. His objects ought not to be more capable than he is.
609 Test your file-based application
without the file system
Let us return to the Coffee Shop application we examined in part 2. Suppose we would like to read the coffee catalog data as comma-delimited text stored in a file. We would need a class to read the file, parse the text, and create a Coffee- Catalog object. We might end up with the simple class in listing 17.3.
package junit.cookbook.coffee.data;
import java.io.*;
import java.util.regex.*;
import junit.cookbook.coffee.model.CoffeeCatalog;
import com.diasparsoftware.java.util.Money;
public class CoffeeCatalogFileReader {
private Pattern catalogLinePattern = Pattern .compile("(.+),(.+),(.+)");
public CoffeeCatalog load() throws IOException { CoffeeCatalog catalog = new CoffeeCatalog();
BufferedReader reader = new BufferedReader(
new FileReader(new File("data/catalog.txt")));
while (true) {
String line = reader.readLine();
if (line == null) break;
Matcher matcher = catalogLinePattern.matcher(line);
if (matcher.matches()) {
String productId = matcher.group(1);
String coffeeName = matcher.group(2);
String unitPriceAsString = matcher.group(3);
Money unitPrice = Money .parse(unitPriceAsString);
catalog.addCoffee(productId, coffeeName, unitPrice);
} }
return catalog;
} }
We typed this all in without even testing it. (Calm down.) Because we have not tested it at all, we want to do that now, but the only way to test this code as is involves using the real catalog file and verifying the data it contains. This is straightforward, so let us test it. Here is our production coffee catalog file.
Listing 17.3 CoffeeCatalogFileReader
610 CHAPTER 17 Odds and ends
762,Sumatra,$7.50 800,Special Blend,$9.50 900,Colombiano,$10.00
Listing 17.4 shows our test.
package junit.cookbook.coffee.data.test;
import junit.cookbook.coffee.data.CoffeeCatalogFileReader;
import junit.cookbook.coffee.model.CoffeeCatalog;
import junit.framework.TestCase;
import com.diasparsoftware.java.util.Money;
public class CoffeeCatalogFileTest extends TestCase { public void testReadCatalogFile() throws Exception {
CoffeeCatalogFileReader reader = new CoffeeCatalogFileReader();
CoffeeCatalog expected = new CoffeeCatalog();
expected.addCoffee("762", "Sumatra", Money.dollars(7, 50));
expected.addCoffee("800", "Special Blend", Money.dollars(9, 50));
expected.addCoffee("900", "Colombiano", Money.dollars(10, 0));
assertEquals(expected, reader.load());
} }
There is one little problem with our test: it cannot pass. The problem is simple: we keep our tests in a separate Eclipse project and the CoffeeCatalogFileReader hard codes a relative filename—relative to the directory in which its Eclipse project is located. Either we move CoffeeCatalogFileReader into the test’s project or the test into the file reader’s project. Neither option is particularly good, so we need to refactor. We add a parameter to CoffeeCatalogFileReader.load() so that it can accept the file from which to load. Listing 17.5 shows the new version of the test.
package junit.cookbook.coffee.data.test;
import java.io.File;
import junit.cookbook.coffee.data.CoffeeCatalogFileReader;
import junit.cookbook.coffee.model.CoffeeCatalog;
import junit.framework.TestCase;
import com.diasparsoftware.java.util.Money;
public class CoffeeCatalogFileTest extends TestCase { public void testReadCatalogFile() throws Exception { Listing 17.4 CoffeeCatalogFileTest
Listing 17.5 CoffeeCatalogFileTest with a relative filename
TE AM FL Y
Team-Fly®
611 Test your file-based application
without the file system
CoffeeCatalogFileReader reader = new CoffeeCatalogFileReader();
CoffeeCatalog expected = new CoffeeCatalog();
expected.addCoffee("762", "Sumatra", Money.dollars(7, 50));
expected.addCoffee("800", "Special Blend", Money.dollars(9, 50));
expected.addCoffee("900", "Colombiano", Money.dollars(10, 0));
assertEquals(
expected, reader.load(
new File("../CoffeeShopEngine/data/catalog.txt")));
} }
Now the test passes, but there is still a problem: what happens when, next week, someone adds a few new coffee products to the catalog? When this happens, the test will fail, and really for no good reason. In order to avoid this, we ought to use a different file. We copy the production catalog file to a local directory and change our test accordingly, to the version in listing 17.6.
package junit.cookbook.coffee.data.test;
import java.io.File;
import junit.cookbook.coffee.data.CoffeeCatalogFileReader;
import junit.cookbook.coffee.model.CoffeeCatalog;
import junit.framework.TestCase;
import com.diasparsoftware.java.util.Money;
public class CoffeeCatalogFileTest extends TestCase { public void testReadCatalogFile() throws Exception {
CoffeeCatalogFileReader reader = new CoffeeCatalogFileReader();
CoffeeCatalog expected = new CoffeeCatalog();
expected.addCoffee("762", "Sumatra", Money.dollars(7, 50));
expected.addCoffee("800", "Special Blend", Money.dollars(9, 50));
expected.addCoffee("900", "Colombiano", Money.dollars(10, 0));
assertEquals(
expected,
reader.load(new File("test/data/catalog.txt")));
} }
Even better, but now we have a complex test environment. If someone moves our test file, or forgets to copy it to the right place, or someone decides to change its
Listing 17.6 CoffeeCatalogFileTest using a test data directory
612 CHAPTER 17 Odds and ends
contents—any of these things results in a false failure. It would be better just to put the test data right next to the test itself, to essentially eliminate the possibility of someone changing one without the other. The simplest solution is to parse the information from a String. In order to do this, we need CoffeeCatalog- FileReader.load() to accept a Reader, not a File, as its parameter. It is nice to see that with Java’s well-designed I/O library, this is an easy change. Listing 17.7 shows the new test.
package junit.cookbook.coffee.data.test;
import java.io.StringReader;
import junit.cookbook.coffee.data.CoffeeCatalogFileReader;
import junit.cookbook.coffee.model.CoffeeCatalog;
import junit.framework.TestCase;
import com.diasparsoftware.java.util.Money;
public class CoffeeCatalogFileTest extends TestCase { public void testReadCatalogFile() throws Exception { String catalogText =
"762,Sumatra,$7.50\r\n"
+ "800,Special Blend,$9.50\r\n"
+ "900,Colombiano,$10.00\r\n";
CoffeeCatalog expected = new CoffeeCatalog();
expected.addCoffee("762", "Sumatra", Money.dollars(7, 50));
expected.addCoffee("800", "Special Blend", Money.dollars(9, 50));
expected.addCoffee("900", "Colombiano", Money.dollars(10, 0));
CoffeeCatalogReader reader = new CoffeeCatalogReader();
assertEquals(
expected,
reader.load(new StringReader(catalogText)));
} }
We have not had to change the production code much, either, except to rename the class. After all, it does not read from a file any more. Listing 17.8 shows the final version, with the changes highlighted in bold print.
package junit.cookbook.coffee.data;
import java.io.*;
Listing 17.7 CoffeeCatalogFileTest with an inline file
Listing 17.8 CoffeeCatalogReader
613 Test your file-based application
without the file system
import java.util.regex.*;
import com.diasparsoftware.java.util.Money;
import junit.cookbook.coffee.model.CoffeeCatalog;
public class CoffeeCatalogReader { private Pattern catalogLinePattern = Pattern.compile("(.+),(.+),(.+)");
public CoffeeCatalog load(Reader catalogDataReader) throws IOException {
CoffeeCatalog catalog = new CoffeeCatalog();
BufferedReader reader = new BufferedReader(catalogDataReader);
while (true) {
String line = reader.readLine();
if (line == null) break;
Matcher matcher = catalogLinePattern.matcher(line);
if (matcher.matches()) {
String productId = matcher.group(1);
String coffeeName = matcher.group(2);
String unitPriceAsString = matcher.group(3);
Money unitPrice = Money.parse(unitPriceAsString);
catalog.addCoffee(productId, coffeeName, unitPrice);
} }
return catalog;
} }
But what about reading from a file? The newly named CoffeeCatalogReader now has no dependency at all on the source of the data, but only its format. Whichever object uses the CoffeeCatalogReader is now responsible for providing it with a valid Reader object configured to read well-formed catalog data. Perhaps the Coffee- ShopController servlet should have this responsibility. If so, we can use a mock objects approach to verify that it provides the proper parameter to CoffeeCatalog- Reader.load(). Not only is the test very robust (no dependency on external resources), but the design is more flexible. If someone needs to read catalog data from a network connection, they can do it without changing CoffeeCatalog- Reader at all. The Open/Closed Principle at work!2
2 www.objectmentor.com/resources/articles/ocp.pdf
614 CHAPTER 17 Odds and ends
◆ Discussion
If you are given a legacy system that interacts with files, then you might not be able to apply this recipe. In that case, you need to cope with the application’s dependency on the file system. See recipe 17.1 to achieve test isolation in the face of such a dependency.
◆ Related
■ 17.1—Clean up the file system between tests
■ Open/Closed Principle
(www.objectmentor.com/resources/articles/ocp.pdf)
■ Single Responsibility Principle
(www.objectmentor.com/resources/articles/srp)