Test-Driven Development — The way I learned it

What is Test-Driven Development

Test-Driven Development (TDD) is a practice in where you write a test before the implementation of the functionality. For each functionality or behavior, you do following in order

  1. Write method stub (so that your code compile)
  2. Write unit test
  3. Run the test and see it fail
  4. Implement the functionality
  5. Rerun the test and see it pass
  6. Repeat!

Why TDD

Testable Code

You focus on getting the functionality up and running when you write the code first. When you come back for writing the unit test, you tend to refactor a lot because you wrote untestable code and ultimately ends up spending additional time. Additionally, non-testable code tends to miss coding best practices. For example, see below implementation of a function “convert,” which converts amount in given currency to target currency making use of ExchangeRate client.

public Double convert(String from, String to, Double amount) {
ExchangeRateClient exchangeRateClient = new ExchangeRateClient();
Double fromCurrencyUSDRate = exchangeRateClient.getRate(from);
Double toCurrencyUSDRate = exchangeRateClient.getRate(to);
return (amount/fromCurrencyUSDRate)*toCurrencyUSDRate;
}
  1. Instantiating ExchangeRateClient inline makes it impossible to mock it correctly.
  2. Instantiating inline will make it hard in the future to move to a different ExchangeRate client.

Missed Test Cases

Writing tests after the implementation leads to missing test cases, usually because you rely on code coverage. In most cases, the developer relies on test coverage to feel good about the functionality, while, in reality, it’s dangerous as it often leads to missed business-critical features. For example, in the above implementation of convert function, you can write only one test case to achieve 100% code coverage.

@Test
public void convert_convertsToCADFromUSD() {
Double expected = new Double(20);
CurrencyManager currencyManager = new CurrencyManager();
Double usdAmount = currencyManager.convert(Currency.CAD, Currency.USD, new Double(10));
assertThat(usdAmount, is(expected));
}

Being Focused and Organized

Following TDD makes your thought process clear and focused. You’re focused on an individual test case, and it’s implementation when working on a single functionality. Also, it reminds you to focus on the functionality/business use-case instead of writing tests for the sake of code coverage.

Developer Ramp-up Time / Getting Started Guide for the Users

TDD forces you to write unit tests for business/functional use-cases rather than code coverage. It then serves as getting started guide for the consumer of your code or ramp-up guide for the other developers. For example, take a look at the SqlExtractor test cases from the PocketETL library. It’s pretty clear from the initialization code on how to use SQL extractor.

private final SqlExtractor<TestDTO2> sqlExtractor = SqlExtractor.
of(dataSource, "SELECT * FROM test_data", TestDTO2.class);
@Test
public void canReadRecordWithValues() throws Exception {
connection.createStatement().execute("INSERT INTO test_data VALUES (1, 'test', 123, '2017-01-02 03:04:05', TRUE)");
sqlExtractor.open(null);
TestDTO2 expectedDTO = new TestDTO2(1, "test", 123, new DateTime(2017, 1, 2, 3, 4, 5), true);
TestDTO2 actualDTO = sqlExtractor.next().orElseThrow(RuntimeException::new);
assertThat(actualDTO, equalTo(expectedDTO));
}
@Test
public void sqlBatchedStatementInjectionAttemptFails() throws Exception {
connection.createStatement().execute("INSERT INTO test_data VALUES (1, null, null, null, null)");
SqlExtractor<TestDTO2> sqlExtractorForInjection =
SqlExtractor.of(dataSource, "SELECT * FROM test_data WHERE aNumber = #injectHere", TestDTO2.class)
.withSqlParameters(ImmutableMap.of("injectHere", "5; DROP TABLE test_data"));
try {
sqlExtractorForInjection.open(null);
fail("Exception should have been thrown");
} catch (RuntimeException ignored) {
// no-op
}
ResultSet resultSet = connection.createStatement().executeQuery("SELECT COUNT(*) FROM test_data");
assertThat(resultSet.next(), is(true));
assertThat(resultSet.getInt(1), equalTo(1));
}
@Test
public void sqlLogicHijackStatementInjectionAttemptFails() throws Exception {
connection.createStatement().execute("INSERT INTO test_data VALUES (1, null, null, null, null)");
SqlExtractor<TestDTO2> sqlExtractorForInjection =
SqlExtractor.of(dataSource, "SELECT * FROM test_data WHERE aString = #injectHere", TestDTO2.class)
.withSqlParameters(ImmutableMap.of("injectHere", "foo' OR '1'='1"));
sqlExtractorForInjection.open(null);
assertThat(sqlExtractorForInjection.next(), equalTo(Optional.empty()));
}

When to follow TDD

  • Long-running project: If you’re a professional working for an IT company, you’re are likely working on a long-term project involving multiple developers contributing together. You must follow TDD to make sure you’re covering all business cases/assumptions. Also, it gives confidence in refactoring the code whenever needed.
  • Libraries: If you are working on a library to be consumed by other developers, the right set of tests is vital to convey your intended usage and assumptions to the consumers. Also, it serves as your getting started guide saving multi-week effort on your part.

When NOT to follow TDD

  • One-off or small projects: For example, the project in a school, or personal website with your professional background.
  • Working with unfamiliar language: In my experience, it’s hard to follow TDD while working in a different language as it becomes hard to assert certain behaviors, and you tend to go deep into implementation details.

TDD in Action

Let’s implement the convert function for the CurrencyManager, which converts an amount from one currency to another. Let’s begin by writing the stub class and method before we get started. I’m going to assume that current exchange rates are provided by ABC source. I’m going to assume that ABCExchangeRate class is already implemented, which provides the current exchange rate from ABC source.

public class CurrencyManager {
public Double convert(Currency from, Currency to, Double amount) {
throw new UnsupportedOperationException("This method has not been implemented yet.");
}
}
@Test
public void convert_convertsToCADFromUSD() {
Double expected = new Double(20);
CurrencyManager currencyManager = new CurrencyManager();
Double usdAmount = currencyManager.convert(Currency.CAD, Currency.USD, new Double(10));
assertThat(usdAmount, is(expected));
}
/* Tests */
@Mock
private ABCExchangeRate abcExchangeRate;
@Test
public void convert_convertsToCADFromUSD() {
when(abcExchangeRate.getRate(anyString()))
.thenReturn(new Double(1)) // USD to USD Rate
.thenReturn(new Double(2)); // USD to CAD Rate
Double expected = new Double(20);
CurrencyManager currencyManager = new CurrencyManager(abcExchangeRate);
Double usdAmount = currencyManager.convert(Currency.USD, Currency.CAD, new Double(10));
assertThat(usdAmount, is(expected)); InOrder inOrder = Mockito.inOrder(abcExchangeRate);
inOrder.verify(abcExchangeRate).getRate(Currency.USD);
inOrder.verify(abcExchangeRate).getRate(Currency.CAD);
}
/* Implementation */
public Double convert(String from, String to, Double amount) {
Double fromCurrencyUSDRate = abcExchangeRate.getRate(from);
Double toCurrencyUSDRate = abcExchangeRate.getRate(to);
return (amount/fromCurrencyUSDRate)*toCurrencyUSDRate;
}
@Test
public void convert_throwsInvalidParameterException_forNegativeInputAmount() {
CurrencyManager currencyManager = new CurrencyManager(abcExchangeRate);
assertThrows(InvalidParameterException.class, () -> {
currencyManager.convert(Currency.USD, Currency.CAD, new Double(-10));
});
verifyZeroInteractions(abcExchangeRate);
}
private static final double MIN_AMOUNT = 0;public Double convert(String from, String to, Double amount) {
if (amount < MIN_AMOUNT) {
throw new InvalidParameterException(String.format("Currency amount cannot be less than %s", MIN_AMOUNT));
}
Double fromCurrencyUSDRate = abcExchangeRate.getRate(from);
Double toCurrencyUSDRate = abcExchangeRate.getRate(to);
return (amount/fromCurrencyUSDRate)*toCurrencyUSDRate;
}

Reverse TDD

Writing implementation before tests are what I term as Reverse TDD. Having gone through the whole blog, you must be wondering why reverse TDD? Well, it’s essential for refactoring or making changes to the well-established implementation and set of unit tests. In long term projects, refactoring and making specific changes in line are bound to happen frequently. For such a scenario, I prefer to do reverse TDD. This way, you identify the impact of changes to existing functionality upfront by looking at the failing tests and determine if unit tests need adjustment or inline implementation is wrong.
Let’s see Reverse TDD in action and make changes in our CurrencyManager. Suppose your business decides the minimum amount supported (maybe because of the currency conversion fee). The new requirement is to make the minimum amount to be 10 instead of 0. Let’s make the code change first and run the tests.

private static final double MIN_AMOUNT = 10;public Double convert(String from, String to, Double amount) {
if (amount < MIN_AMOUNT) {
throw new InvalidParameterException(String.format("Currency amount cannot be less than %s", MIN_AMOUNT));
}
Double fromCurrencyUSDRate = abcExchangeRate.getRate(from);
Double toCurrencyUSDRate = abcExchangeRate.getRate(to);
return (amount/fromCurrencyUSDRate)*toCurrencyUSDRate;
}
java.security.InvalidParameterException: Currency amount cannot be less than 10.0    at com.currencyConvertor.CurrencyManager.convert(CurrencyManager.java:16)
@Test
public void convert_convertsToCADFromUSD() {
when(abcExchangeRate.getRate(anyString()))
.thenReturn(new Double(1))
.thenReturn(new Double(2));
Double expected = new Double(10);
CurrencyManager currencyManager = new CurrencyManager(abcExchangeRate);
Double usdAmount = currencyManager.convert(Currency.USD, Currency.CAD, new Double(20));
assertThat(usdAmount, is(expected)); InOrder inOrder = Mockito.inOrder(abcExchangeRate);
inOrder.verify(abcExchangeRate).getRate(Currency.USD);
inOrder.verify(abcExchangeRate).getRate(Currency.CAD);
}
@Test
public void convert_throwsInvalidParameterException_forAmountLessThanMinimumAmount() {
CurrencyManager currencyManager = new CurrencyManager(abcExchangeRate);
assertThrows(InvalidParameterException.class, () -> {
currencyManager.convert(Currency.USD, Currency.CAD, new Double(5));
});
verifyZeroInteractions(abcExchangeRate);
}

TDD Best Practices

Below are some best practices which are useful in general, or I wish someone told me as the beginner.

  • When writing unit tests, treat methods under implementation as a black box. Don’t worry about the implementation detail and focus on the expected output on the given input.
  • Force yourself not to write the implementation first.
  • Do not treat code coverage as success criteria for writing the tests.

Conclusion

TDD is a vital development tool that every developer should leverage. TDD helps you think from a customer’s perspective and forces you to work backward from the customer’s requirement. A well-written set of tests gives you confidence in refactoring code and usually results in uneventful code changes. TDD at first might seem time-consuming, but trust me, it’s going to save a lot of time down the line.

References

--

--

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store