6.3 Big Dummies


6.3 Big Dummies

Its simplicity makes the Euro calculator example attractive. It does nothing but replace a complex function from the real world by hardwired values, which are exactly tuned to the tests. Let's look now at a more complex problem:

In most applications, we need a way to log a large variety of events in a central place during the program runtime. To be able to implement this logging functionality consistently over the program, we define a standardized interface:

 public interface Logging {    int DEFAULT_LOGLEVEL = 2;    void log(int logLevel, String message);    void log(int logLevel, String message, String module);    void log(String message); } 

This interface allows us to log a message and state a log level, the parameter logLevel, to distinguish between error messages and debug messages. In addition, we need a way to work without an explicit log level, to have instead a standard value, DEFAULT_LOGLEVEL.

A first implementation of the Logging interface should be the class LogServer, which is created by stating a file name and used to write all log entries to this file. As usual, we start with a test:

 public class LogServerTest extends TestCase {    public void testSimpleLogging() {       Logging logServer = new LogServer("log.test");       logServer.log(0, "line One");       logServer.log(1, "line Two");       logServer.log("line Three");       // assertTrue(??) Oops, and now?    } } 

While the first four lines were easy game for us, we are now in a dilemma. How do we get inside the log.test file to check whether or not the log server really does its job properly? One way to check this would be to expand our log server by a function, getLoggingFile(). However, this would mean that we commit ourselves to file logging and disclose an implementation detail merely for test purposes. In addition, opening and reading a file could cause problems that make controlled and repeated test runs more difficult:

  • How do I find a path that is readable and writable for test purposes?

  • How can I ensure that the access rights on this path are correct?

  • How can I ensure that the file doesn't already exist or wasn't deleted before the test?

All of these are problems we have to consider for productive operation, but should be of no meaning for the current state of development.

This is where our knowledge of the Java IO classes comes in handy. How about simply passing an instance of the java.io.PrintWriter type within the constructor to our LogServer, instead of using a file name. This offers a way to output our log message by use of println() and it is useful not only for files but for any type of OutputStream. Therefore, we will modify our test as follows:

 public void testSimpleLogging() {    PrintWriter writer = new PrintWriter(                         new FileOutputStream("log.test"));    Logging logServer = new LogServer(writer);    logServer. log(0, "line One");    logServer.log(1, "line Two");    logServer.log("line Three");    // assertTrue(??) Oops, and now? } 

Unfortunately, we are still facing the problem that we first have to get a hold of the file to be able to do the necessary checks. On the upside, knowing that our log server should do nothing but use the println(...) method to output a combination of log level and log message leads to another intuitive idea. Why not implement our own subclass of PrintWriter, as in the previous Euro calculator example? The instances of the PrintWriter subclass record everything they get from println(), and everything would be available for later checks. This idea produces the following dummy class:

 import java.io.PrintWriter; import java.util.*; public class DummyPrintWriter extends PrintWriter {    private List logs = new ArrayList();    public DummyPrintWriter() {       super((OutputStream) null);    }    public void println(String logString) {       logs.addElement(logString);    }    public String getLogString(int pos) {       return (String) logs.get(pos);    } } 

With the help of this DummyPrintWriter class, we can now formulate our test much more simply and clearly:

 public void testSimpleLogging(){    DummyPrintWriter writer = new DummyPrintWriter();    Logging logServer = new LogServer(writer);    logServer.log(0, "first line");    logServer.log(1, "second line");    logServer.log("third line");    assertEquals("0: first line", writer.getLogString(0));    assertEquals("1: second line", writer.getLogString(1));    assertEquals("2: third line", writer.getLogString(2)); } 

Looks like we made it, or did we? Taking a closer look, we can see that our test still contains a few ugly things. To make DummyPrintWriter a useful subclass of PrintWriter, we had to use a few tricks. On the one hand, our constructor does a cast on the null object; this is ugly but necessary to allow the Java compiler to statically determine the correct super constructor. On the other hand, we dangerously assumed that our log server would exclusively call the println(...) method of the PrintWriter instance. Why not simply turn this implicit assumption into an explicit one by introducing an interface? Well said; now let's do it:

 public interface Logger {       void logLine(String logString); } 

Of course, this also changes the constructor of our log server and thus the test:

 public void testSimpleLogging() {    DummyLogger logger  = new DummyLogger();    Logging logServer = new LogServer(logger);    logServer.log(0, "first line");    logServer.log(1, "second line");    logServer.log("third line");    assertEquals("0: first line", logger.getLogString(0));    assertEquals("1: second line", logger.getLogString(1));    assertEquals("2: third line", logger.getLogString(2)); } 

And our DummyPrintWriter turns into a DummyLogger:

 import java.util.*; public class DummyLogger implements Logger {    private List logs = new ArrayList();    public void logLine(String logString) {       logs.add(logString);    }    public String getLogString(int pos) {       return (String) logs.get(pos);    } } 

The actual implementation of LogServer appears trivial, compared to our efforts in making everything "testable":

 public class LogServer implements Logging {    private Logger logger;    public LogServer(Logger logger) {       this.logger = logger;    }    public void log(int logLevel, String message) {       String logString = logLevel + ": " + message;       logger.logLine(logString);    }    public void log(String message) {       this.log(DEFAULT_LOGLEVEL, message);    } } 

Now, was this really worth all the effort? Don't we see at a glance that the class does exactly what it is supposed to do? Before answering this question, it is worthwhile to have a look at what we achieved with the introduction of our dummies and what we didn't achieve. By introducing the Logger interface, we obtained a log server with an implementation independent of system-specific IO classes. This interface also allows us to implement different loggers that our server can use without modifying them.

In this effort, we have observed important heuristics in object-oriented design, namely the so-called Dependency Inversion Principle [Martin96b, Meade00], which states:

  • High-level modules should not depend upon low-level modules. Both should depend upon abstractions (interfaces). In our example, this means that the log server should not depend on the file logger.

  • Abstractions should, in turn, not depend upon details. Instead, details should depend upon abstractions.

Figure 6.1 shows this simple principle based on a dependence diagram of the HighLevelClass class upon an abstract interface, AbstractServer. The two implementations, ConcreteServer1 and ConcreteServer2, in turn depend only on this interface.


Figure 6.1: Schematic view of the Dependency Inversion Principle.

Another achievement is that we can now test the correct interaction of the log server with its logger in a very simple way. By following the one objective, namely to make our log server testable, we won a second objective for free: a modified design. It is a design that reduces dependencies and increases expandability. However, we (still) do not have a log server that really writes to a file. But that should be no trouble at all for us, considering what we have learned so far. First the test:

 import java.io.*; public class FileLoggerTest extends TestCase {    private final String TEMPFILE = "C:\\temp\\test.txt";    public void testLogLine() throws IOException {       FileLogger logger = new FileLogger(TEMPFILE);       logger.logLine("Line 1");       logger.logLine("Line 2");       logger.close();       BufferedReader reader = new BufferedReader(                               new FileReader(TEMPFILE));       assertEquals("Line 1", reader.readLine());       assertEquals("Line 2", reader.readLine());       assertNull("end of file reached", reader.readLine());       reader.close();    } } 

This test still contains one little ugly thing, namely the dependence upon an absolute file path. But we will discuss this later (see Chapter 6, Section 6.11). And now there is no magic about the implementation of the FileLogger class:

 import java.io.*; public class FileLogger implements Logger {    private PrintWriter writer;    public FileLogger(String filename) throws IOException {       writer = new PrintWriter(new FileOutputStream(filename));    }    public void close() {       writer.close();    }    public void logLine(String logMessage) {       writer.println(logMessage);    } } 

Although the dependencies of our tests upon the file system are not fully eliminated yet, we reduced them to a single one, namely the one that verifies the cooperation with files.




Unit Testing in Java. How Tests Drive the Code
Unit Testing in Java: How Tests Drive the Code (The Morgan Kaufmann Series in Software Engineering and Programming)
ISBN: 1558608680
EAN: 2147483647
Year: 2003
Pages: 144
Authors: Johannes Link

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net