Test-Driven Development — The way I learned it

What is Test-Driven Development

  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

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

@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

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

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

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

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

  • 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

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
Aditya Solge

Aditya Solge

More from Medium

“Mocking” my way into FAANG

Everything You Need to Know About TDD — Part 1

The Unseen Fury of Debugging

What is TDD and why you should learn it