In your production environment, your applications usually have no control of I/O devices and the system clock. However, in tests you want to have control of these external dependencies. Dave Farley greatly motivates this wish of control in the video linked below. Although he talks about acceptance tests, the statement equally holds for unit tests.
In a nutshell: access to I/O devices reduces the performance of your test and the reliability of your test results. If your application uses the system clock to schedule tasks at particular moments in time, the performance is even heavier affected because your test has to wait for the tasks to be executed.
But how can we get rid of these disadvantages? Our goal is to access the I/O devices and the system clock in an ordinary fashion, if our application runs in production, and in a controlled fashion, if it runs somewhere else.
The approach
A simple, but effective approach is to introduce an abstract interface for each I/O device and for the system clock in your application. By default, it uses the ordinary implementations for the interfaces. However, if you run its unit tests, your application uses in-memory implementations instead. In this way, unit tests can run as fast as possible, without any (external) dependencies and independent from each other — that is, they can run in parallel.
In the following sections, we have a look at common use cases.
Get control of the file system!
First, introduce an interface that represents the central access point in your application to all operations on the file system:
public interface Filesystem {
void writeTextToFile(String text, Path path, Charset charset) throws IOException;
List<String> readLinesFromFile(Path path, Charset charset) throws IOException;
// Add further access methods here. At best, you only declare methods, your application really uses. Not more.
}
Second, pass the file system interface to each class in your application that wants to access the file system:
public class ArticleWriter {
private final Filesystem filesystem;
public ArticleWriter(Filesystem filesystem) {
this.filesystem = filesystem;
}
public void write(Article article, Path path) throws IOException {
this.filesystem.writeTextToFile(article.toString(), path, StandardCharsets.UTF_8);
}
}
public class PersonNamesReader {
private final Filesystem filesystem;
public PersonNamesReader(Filesystem filesystem) {
this.filesystem = filesystem;
}
public List<String> read(Path path) throws IOException {
this.filesystem.readLinesFromFile(path, StandardCharsets.UTF_16);
}
}
Third, adapt the available unit tests from these classes:
class ArticleWriterTest {
private Filesystem filesystem;
private ArticleWriter articleWriter;
@BeforeEach
void init() {
filesystem = new UnittestFilesystem();
articleWriter = new ArticleWriter(filesystem);
}
@Test
void withValidArticleAndValidFilePathShouldWriteToFileWithoutException() throws IOException {
// given
Article article = new Article().title("Get Control!").text("lorem ipsum");
String filepath = "./articles/";
// when
ThrowingCallable writeArticleCallable = () -> articleWriter.write(article, Paths.get(filepath));
// then
assertThatCode(writeArticleCallable).doesNotThrowAnyException();
}
class PersonNameReaderTest {
private Filesystem filesystem;
private PersonNamesReader personNamesReader;
@BeforeEach
void init() {
filesystem = new UnittestFilesystem();
personNamesReader = new PersonNamesReader(filesystem);
}
@Test
void withValidFilePathAndTwoPersonNamesShouldReadTwoPersonNames() throws IOException {
// given
String filepath = "./articles/";
List<String> personNamesInFile = Arrays.asList("Dave", "Christian");
filesystem.writeLinesToFile(personNamesInFile, filepath, StandardCharsets.UTF_8);
// when
List<String> personNames = personNamesReader.read(Paths.get(filepath));
// then
assertThat(personNames).containsExactly(personNamesInFile);
}
Fourth, provide the ordinary and the in-memory implementation for the file system interface:
public class JavaNioFilesystem implements Filesystem {
public void writeTextToFile(String text, Path path, Charset charset) {
Files.write(path, text.getBytes(charset));
}
public List<String> readLinesFromFile(Path path, Charset charset) {
return Files.readAllLines(path, charset);
}
}
public class UnittestFilesystem implements Filesystem {
private final Map<String,byte[]) files = new HashMap<>();
public void writeTextToFile(String text, Path path, Charset charset) throws IOException {
files.put(path, text.getBytes(charset));
}
public List<String> readLinesFromFile(Path path, Charset charset) throws IOException {
byte[] fileBytes = files.get(path);
String text = new String(fileBytes, charset);
String[] lines = text.split("\\n");
return Arrays.asList(lines);
}
}
Fifth, configure the default for production (here implemented with Spring):
@Configuration
class BeanDeclarations {
@Bean
Filesystem javaNioFilesystem() {
return new JavaNioFilesystem();
}
@Bean
ArticleWriter articleWriter(Filesystem filesystem) {
return new ArticleWriter(filesystem);
}
@Bean
PersonNamesWriter personNamesWriter(Filesystem filesystem) {
return new PersonNamesWriter(filesystem);
}
}
The UnittestFilesystem is created by hand without injection for each test.
Get control of the socket communication!
First, introduce an interface that represents the central access point in your application to all operations on the system’s sockets:
public interface Socketsystem {
void writeBytesToAddress(byte[] bytes, String ipAddress, int port) throws IOException;
// Add further access methods here. At best, you only declare methods, your application really uses. Not more.
}
Second, third, fourth and fifth are analogous to the file system from above.
Get control of the system’s time!
If your application does something that happens at a scheduled point in time, your unit test should NEVER wait for it. Instead, it should set this point in time such that the scheduled task is executed immediately. The same applies for methods that return values depending on the current point in time, for example, getAge()
or isOverdue()
.
First, introduce an interface that represents the central access point in your application to the system’s time:
public interface SystemTime {
DateTime getCurrentSystemTime();
/** @return the new system's time after traveling (forth or back) the given amount of time. */
DateTime travel(int time, TimeUnit timeUnit);
// Add further access methods here. At best, you only declare methods, your application really uses. Not more.
}
Second, pass the system’s time interface to each class in your application that wants to access the system’s time. Unfortunately, you often need to invest more effort than that when using a scheduler library because it usually uses the operating system’s time directly without any possibilities to manipulate the scheduler’s point in time.
Third, fourth and fifth are analogous to the file system from above.
Analysis of the additional effort
In a nutshell: the additional effort is minimal. Thus, I recommend to always use this approach for any I/O access (including the system’s clock).
In more detail: for each I/O device, you have to introduce an additional interface with its two implementations: the ordinary one and the in-memory alternative. Once declared, you can access the I/O device in a similar way as you would access it directly.
How to enforce this approach?
If you like to enforce this approach in a particular application (or in the whole landscape of your company’s applications), try to formulate a corresponding rule and let it automatically be checked continuously.
One way of realization is to allow, for example, access to the file system only in a single class named “Filesystem”. For all of the other classes in the application applies: access to the Java’s file API is prohibited. Rules for accessing the socket API and the system’s clock are analogous. Thus, the access is limited to a single class with a hard-coded class name.
To enable a step-by-step migration of a large legacy code base, these rules could be restricted to new source code. Alternatively, the number of rule violations in the current commit may never be more than the number determined in the previous commit.