DEV Community

Patrick Charles Höfer
Patrick Charles Höfer

Posted on

How I established TDD into my programming routine

Table Of Contents

Preface

I have been working as a developer for about six years now. I started rather late in this career when I was 27 years old. That said it has always been my inner call to make up for that late start by trying to be as skilled and proficient at programming as possible as I could. Reading about best practices and all the Do's and Don'ts was therefore inevitable. However, one particular topic was pretty hard to grasp for me personally. The problem was not understanding it but more how to apply it to my everyday programming work routine. It was Test Driven Development - or how most people would call it TDD.

TDD really changed my overall efficiency. I was often praised for being disciplined using Test Driven Development. It was not easy for me at first and it took me around two years to really manifest into my daily routine. With this article, I hope to speed up the time to master TDD for other developers.

If you are not familiar with TDD yet just check out this Youtube Tutorial. Afterwards you can read on and see how I managed to bring TDD into my programming routine.

Misconceptions about TDD

When mentioning TDD in companies I worked in I often received a subtle 'sigh' or other eye-roll-related expressions from some of my new colleagues. The common counterarguments were mostly revolving around the idea that TDD is a waste of time and writing code with the TDD paradigm or tests at all are just not proficient for everyday use.

To achieve those benefits you have to "learn" how to write tests. It's similar to learning the API of a new framework or library. At first you're slow with it and often have to look up the documentation. Writing a custom solution might be "faster" one could say. But after you mastered an API you usually are way more efficient and faster in writing solutions covered by those frameworks.

Exactly the same applies for testing and foremost Test Driven Development. However one has to force themself to use TDD. It's tempting to go around and use the 'shortcut' and just write the business logic without TDD or tests at all. Try to avoid those shortcuts. Believe me it's worth it.

Also writing a test is not just writing the test. You first write a test specification. Then you write the business logic to fulfil the specification and afterwards you apply refactoring if necessary. One could claim that at least 50% of the time writing a test is actually writing the actual code or business logic. And the faster you are in writing the test specification the greater is the amount of business logic written under the hood of TD (plus the benefits of TDD!).

In the following chapter I want to point out what things you need to understand and learn to use TDD efficiently.

Things you need to master to be efficient

To reduce the time spent writing tests in terms of Test Driven Development you should know how to use testing frameworks and concepts. The less time you spent on reading up documentation the less time it takes you to write a test and the earlier you can focus on the actual business logic.

Here is a quick listing of things that are important to you to master:

  • Testing Frameworks (JUnit, Jest, Mokka, etc.)
  • Assertion Frameworks (AssertJ, AssertK, Chai, etc.)
  • Mocking Frameworks (MockK, Mockito, etc.)
  • Testing Concepts (TDD, BDD, etc.)
  • Translate task requirements into test specifications

And last but not least: Write tests as often as you can to drill the routine. Writing a test needs to be as trivial to you as writing a function in your favourite programming language.

My TDD routine

In the following I'd like to show an example on how I implement feature specifications using TDD.

Let's start with a fictional feature request. The task description/request might look like this:

Hey there,

I need a service that converts a string to either uppercase or lowercase depending on the first character being capitalised or not.

For example:
"breAD" -> "bread"
"House" -> "HOUSE"
"gIRAFFE" -> "giraffe"

Thanks for your help!

Let's start by using the following steps:

  1. Understand the requirement
  2. Split the requirement into small independent specifications
  3. Implement one by one
    • Write the test as an expected assertion
    • Run the test into failure (Red)
    • Implement business logic of the feature and run the test into success (Green)
    • Refactor implemented business logic if necessary (Refactor)

Understand the requirement

The product owner (or whoever) wants me to implement a feature where a string is converted to an uppercase representation if the first letter of the string is capitalised. If it is not then the string should be converted to all lowercase. Sounds easy.

Split into small and independent problems

The complete task should now be split into smaller chunks of subtasks for the sake of writing small and easy to read (and write) unit tests. In this case, we have only two criteria.

The feature...

  • should convert strings that start with capital letters to uppercase strings
  • should convert strings that start with non-capital letters to lowercase strings

What I do at this point is that I write down those "subtasks" directly as unit test signatures. That way I have a in-code TODO or Acceptance Criteria list and can work on it right away without the need to look into the task description again (usually).

Here is an example written in JUnit5 and Kotlin:

// CaseConverterTest.kt
 @Test
 fun `should convert strings that start with capital letters to uppercase strings`() {}
 @Test
 fun `should convert strings that start with non-capital letters to lowercase strings`() {}
Enter fullscreen mode Exit fullscreen mode

(To remind yourself to implement those specifications you could add assertTrue(false) or something like that to force failure on those unit tests.)

Implement one by one

Now let's get to work on the actual testing logic. As I mentioned in Things to Learn the pace at which you actually write test code depends on your experience with the aforementioned testing frameworks.

In this example we have two classes. The CaseConverter that is the actual business logic class and the CaseConverterTest that runs the unit tests.

Here is the way I setup my test logic before I start to write down any business logic for the feature at all.

// CaseConverterTest.kt

@Test
fun `should convert strings that start with capital letters to uppercase strings`() {
    val caseConverter: CaseConverter = CaseConverter()
    val givenString = "House"
    val expectedString = "HOUSE"

    val convertedString = caseConverter.convert(givenString)

    assertThat(convertedString).isEqualTo(expectedString)
}

Enter fullscreen mode Exit fullscreen mode

Executing this test will result in failure and this is fine. Because we did not implement any business logic just yet and we want to confirm that the test is properly asserting the expected behaviour.

In TDD terms this is the Red step.

This is what my assertion framework (AssertK) tells me in the console:

expected:<"[HOUSE]"> but was:<"[]">
Expected :"[HOUSE]"
Actual :"[]"

Now let's continue to the Green step where we implement the expected business logic.

The nice thing with TDD now is that we can rely on the test assertion while writing business logic. When writing code without tests one always has to build the application, run it and "use" it to assert the expected behaviour by hand. Tests are doing this for you. Why would a developer not want to have that?

Here is the business logic implementation to make the first test specification happy:

// CaseConverter.kt

class CaseConverter {
    fun convert(stringToConvert: String): String {
        return if (stringToConvert.first().isUpperCase()) {
            stringToConvert.uppercase()
        } else {
            ""
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This will result in a successful test execution.

Now let's continue with the next test specification. For this test, I use the given string breAD from the task/feature description above. The resulting string should be all lowercase -> bread. Let's write this as a test.

// CaseConverterTest.kt

@Test
fun `should convert strings that start with non-capital letters to lowercase strings`() {
    val caseConverter: CaseConverter = CaseConverter()
    val givenString = "breAD"
    val expectedString = "bread"

    val convertedString = caseConverter.convert(givenString)

    assertThat(convertedString).isEqualTo(expectedString)
}
Enter fullscreen mode Exit fullscreen mode

Nothing much of a difference here in comparison to the first test specification. Still, it's important to keep these small differences separated from each other.

A developer might be tempted to to extract reusable code. Do not do it in tests. A test needs to be read as is and inline. Reading a test should not result in jumping through different extracted methods/functions. (I am quite dogmatic regarding this topic so that's still just my personal opinion)

If your tests becomes too large and unreadable then that is a definite code smell for your business logic or problem/feature not being split into smaller pieces or separated responsibilities. Do not blame the test. Refactor your business logic.

Now let's continue make the second test happy by adding the expected behaviour to the business logic:

// CaseConverter.kt

class CaseConverter {
    fun convert(stringToConvert: String): String {
        return if (stringToConvert.first().isUpperCase()) {
            stringToConvert.uppercase()
        } else if (stringToConvert.first().isLowerCase()) {
            stringToConvert.lowercase()
        } else {
            ""
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now both tests are going Green. The feature therefore is implemented and the requirements/acceptance criteria fulfilled.

One final thing we can do now is to Refactor. Refactoring being the last step in this case is quite important. Now that you have a unit test that guarantees the expected behaviour for your business logic you can go nuts on the refactoring. Make it as nice as you want to. As long as the test is going Green you are fine.

(To be honest here. A unit test isn't a guarantee for anything since you wrote it by yourself. You have to rely on your own test quality)

For the sake of completeness here is the refactored business logic:

// CaseConverter.kt

class CaseConverter {
    fun convert(stringToConvert: String): String {
        return if (stringToConvert.first().isUpperCase()) {
            stringToConvert.uppercase()
        } else stringToConvert.lowercase()
    }
}
Enter fullscreen mode Exit fullscreen mode

The tests are still Green and everyone is happy.

That's it about my way of using TDD in my programming routine. I managed to start with test specifications instead of business logic and am very happy with this style. I could never go back from this, and I feel very uncomfortable working on an established codebase where it's troublesome or even impossible to write tests for.

I hope I could inspire some of you non-testing developers to join the "right" path ;)

Discussion (0)