Test-Driven Development — The way I learned it

Aditya Solge
7 min readMar 7, 2020

--

Disclaimer: I’ve not taken formal training, nor have I read any official books or documentation around TDD. This blog contains information based on my practice and learning from colleagues.

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;
}

When you’ll go and write the unit test for the above method, you’ll notice two major problems with the implementation

  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));
}

The above tests resulted in 100% code coverage, but if you pass a negative amount, code will behave incorrectly.

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));
}

While the following test cases show, the code is resilient to SQL injection.

@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.");
}
}

Now let’s write a test case. I usually start with a happy case. Remember, we don’t need to worry about the implementation yet. Consider the convert function as a black box and focus on what output it’ll produce for specific input.

@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));
}

Run the test and see it fail. Now it’s time to write the implementation and focus on getting just this case running. As I start writing the implementation, I realize I need ABCExchangeRate object to get the exchange rate. Since the exchange rate changes frequently and it’s a unit test case, I’ll need to mock it. For which, I need to dependency inject it as making the object in line will make it hard to mock. Let’s make those adjustments.

/* 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;
}

Let’s write another unit test for validating input.

@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);
}

The test failed with an error that the exception was not thrown. Let’s write the implementation now.

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;
}

And now the test pass.

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;
}

Now you’ll notice that a happy case unit test will fail.

java.security.InvalidParameterException: Currency amount cannot be less than 10.0    at com.currencyConvertor.CurrencyManager.convert(CurrencyManager.java:16)

This way, you realized which functionality is impacted, and you can make a call, whether it’s failing for a valid reason. Let’s correct the happy test case.

@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);
}

Now all test cases will pass. Finally, it’s time to add a new test covering new functionality.

@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

--

--

Aditya Solge
Aditya Solge

Written by Aditya Solge

I'm a full-time freelance software developer who used to work at Amazon. I help students with affordable learning at www.gogettergeeks.com.

No responses yet