The basics, how to test simple programs without using interfaces
From Coder's Log
Where do we begin when we need to refactor legacy code for unit testing. Writing it from scratch isn't always as trivial as we would like, how do we balance the need to have everything implement an interface, without spending more time maintaining the design then the actual code.
I figured it would be a good idea to document what steps I take to refactor code for reuse and testing. Reuse and testing tend to go together more often then not. If your code is broken up into units and its easy to test, its probably easy to reuse as well.
Every task can be decomposed into two elements
- What every task is supposed to do
- What every task needs in order to do the first.
Sounds a bit circular to give an example: A simple task of printing the data from a database to the screen can be decomposed into "Getting data from the database" and "Printing the data to the screen". Whenever you work in a test driven environment it is always dangerous to say that everything must be tested. In this case your task is to print some data to the screen that came from some data base, the task is not to validate that mysql jdbc driver is functioning properly with the particular sql statement that you have. That should be a different task. Knowing what to test and what not to test ultimately comes with experience. I use this rule of thumb to help me out sometimes "If I can't say what this particular method does in one or two sentences then I probably should break it up into smaller pieces."
Lets start with an example
public class FooQuoteToCsvConverter {
public static void main(String[] args) throws Exception {
Class.forName("com.mysql.jdbc.Driver").newInstance();
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/foo", "user", "foo");
Statement s = conn.createStatement();
s.executeQuery("SELECT symbol, company_name FROM stock_symbols");
ResultSet rs = s.getResultSet();
System.out.println("Symbol, Company_Name");
while (rs.next()) {
String symbol = rs.getString("symbol");
String companyName = rs.getString("company_name");
System.out.println(symbol + "," + companyName);
}
rs.close();
s.close();
conn.close();
}
}
In this example we are connecting to a database retrieving a stock symbol and company name, and outputting to standard output a csv which could then be redirected to a file. Very simple few lines of code, but in addition to a lack of proper exception handling the code suffers from a bug if the company name contains a ",". Lets also consider how much effort you need to test this code. You need access to a database with a known dataset, a couple of csv files with all possible use cases. Sounds like quite a bit of work to handle do all that just to test a couple of lines of code, lets see if we can break this code up to be for easier testing.
The code has two parts, the part that outputs the csv info to standard output and the part that retrieves data from the database.
Class.forName("com.mysql.jdbc.Driver").newInstance();
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/foo", "user", "foo");
Statement s = conn.createStatement();
s.executeQuery("SELECT symbol, company_name FROM stock_symbols");
ResultSet rs = s.getResultSet();
.
.
.
rs.close();
s.close();
conn.close();
System.out.println("Symbol, Company_Name");
while (rs.next()) {
String symbol = rs.getString("symbol");
String companyName = rs.getString("company_name");
}
Both pieces of this code are potential candidates to be tested, but one is clearly the task we are trying to accomplish and the other is just support code. I will make a disclaimer now and say that it is very important to test if your statements, resultsets, and connections are properly closed, but due to the design of java.sql.* it is not quite straight forward. While we can't control java.* package we can certainly control our own code, and the best thing we can do is to isolate our code from other people's code as much as possible by either using methods or interfaces. The csv portion of the code is still too tightly coupled to the java.sql.* package and testing it would require you to provide an implementation of the ResultSet class, which is less than pleasant. Rewriting the code as such will decouple the csv logic from database logic.
printCsvLine("Symbol, Company_Name");
while (rs.next()) {
String symbol = rs.getString("symbol");
String companyName = rs.getString("company_name");
printCsvLine(symbol,companyName);
}
public void printCsvLine(String symbol, String companyName) {
System.out.println(symbol + "," + companyName);
}
Now the code that prints the csv line no longer knows anything about a ResultSet, but if you look closer it is still coupled to something. While "System.out" seems harmless, consider how it would work in a test case, The purpose of the test will eventually be to test the output of this code, while overriding System.out is possible, its probably not the cleanest solution.
System.out.println(createCsvLine("Symbol", "Company_Name"));
while (rs.next()) {
String symbol = rs.getString("symbol");
String companyName = rs.getString("company_name");
System.out.println(createCsvLine(symbol,companyName));
}
protected static String createCsvLine(String symbol, String companyName) {
return symbol + "," + companyName;
}
Now we have effectively decoupled the code we want to test from the rest of the code, and the code also happens to be in its simplest form. And we can begin writing our test case, we want to handle both a simple case and a case where we have a comma in the company name.
public class FooQuoteToCsvConverterTests extends TestCase {
public void testSimple() {
assertEquals("S,COMPANY", FooQuoteToCsvConverter.createCsvLine("S", "COMPANY"));
}
public void testComplex() {
assertEquals("S,'COMPANY , WITH A COMMA'", FooQuoteToCsvConverter.createCsvLine("S", "COMPANY , WITH A COMMA"));
}
}
Now to fix the bug, the first thought is probably to change the line that concatenates the strings together to
return symbol + ",'" + companyName+"'";
How ever this will fail the first test that we have, and for some reason our specifications specifically say that if there is no comma then the name should not be escaped. Our final solution needs to work for both cases
public static String createCsvLine(String symbol, String companyName) {
String quotedCompany = companyName.contains(",") ? "'" + companyName + "'" : companyName;
return symbol + "," + companyName;
}
The code above does exactly that, or does it. Made a silly mistake and forgot to use quotedCompany (yes I really did miss this) lucky me the test case caught it and it was an easy fix. The final version follows.
public static String createCsvLine(String symbol, String companyName) {
String quotedCompany = companyName.contains(",") ? "'" + companyName + "'" : companyName;
return symbol + "," + quotedCompany;
}
While this might seem like an over simplified example but writing more of simpler code with unit tests will yield a better product in the end. We all make mistakes, writing tests cases gives us an opportunity to look at our code from another perspective, and lowers the chance of failure.
For next steps Better test coverage and reuse with interfaces and mocks [[
