post hero image
post user image

Arsalan Rana

Senior Software Engineer

Embracing test-driven development

The benefits of test-driven development

Why I considered TDD

When I joined this dynamic team, I experienced many personal firsts: working on a consumer-facing product, getting exposed to GraphQL, working with various Go libraries (echo, gomock, ginkgo, gomege, and the Test-Driven Development (TDD) methodology.

In TDD, you write the tests for your code before you even write the code itself. It initially felt counterintuitive, slow, and tedious. I was used to the traditional way of coding: write the code first, and then test it. But at Fora, TDD was the norm, and I had to adapt.

Before diving deep into the TDD waters at Fora, I had previously heard praises sung by many in the developer community – the promise of cleaner code, reduced debugging time, and increased confidence in releases was enticing. Additionally, there was a lot of emphasis on how TDD could lead to better software design and a better understanding of requirements even before a single line of actual code was written. I found these reasons compelling, so I began adopting this approach.

It wasn’t long before I witnessed TDD's impact. We had expanded our backend GraphQL endpoint to incorporate a new field, "web domain name", into the response for an existing query resolver. However, during the update, our tests highlighted an issue. Another field, "domain name", started returning an empty response. This immediate feedback from the tests pinpointed an oversight. In our refactoring process for the new field, we had inadvertently disrupted the assignment mapping, causing the "domain name" to miss its assignment and default to an empty string. With the clarity provided by our test suite, we swiftly rectified the problem, ensuring both the "domain name" and "web domain name" fields were populated accurately.

Getting started with TDD

To illustrate the TDD process, let's walk through an example in Go when I needed to write a function that sums a given input of numbers. Here’s how I approached it:

Step 1: Start simple

Knowing the end goal, I still began with a basic function signature. This allowed me to create a foundation that could be expanded upon.

func Add(a int, b int) int {
    // implementation goes here
}

Step 2: Write the test and have it fail

Before delving into the implementation, as per TDD principles, I decided to write a simple test knowing that it would fail.

func TestAdd(t *testing.T) {
    a, b := 2, 3
    expected := a + b
    result := Add(a, b)
    if result != expected {
        t.Errorf("Expected %d, but got %d", expected, result)
    }
}

Step 3: Flesh out the function until green

Now I could implement the function. I started by purposely writing a naive and incorrect implementation of Add that just satisfies the test’s requirement.

func Add(a int, b int) int {
    return 5
}

Running the test again showed that it passed.

Step 4: Write more tests

I had initially tested this function with the input numbers 2 and 3. What would happen if the inputs were different numbers? I decided to add another test to cover this case.

func TestAddWithDifferentNumbers(t *testing.T) {
    a, b := 7, 8
    expected := a + b
    result := Add(a, b)
    if result != expected {
        t.Errorf("Expected %d, but got %d", expected, result)
    }
}

Unsurprisingly, the test failed because our implementation of the Add function is a hardcoded response.

Step 5: Refactor

I updated the naive implementation of the Add function that can correctly evaluate the sum of the inputs while ensuring all of our tests pass:

func Add(a int, b int) int {
    return a + b
}

Running all our tests showed that they all passed. I had successfully implemented our Add function!

Step 6: Adding new functionality

Considering the long-term goal, I decided to adapt my tests to a more dynamic input style. This would pave the way for our function's scalability.

Before I started to change our function, I first updated the tests to reflect the new requirements:

func TestAddWithMultipleNumbers(t *testing.T) {
    numbers := []int{1, 2, 3, 4, 5}
    expected := 0
    for _, num := range numbers {
        expected += num
    }
    result := Add(numbers...)
    if result != expected {
        t.Errorf("Expected %d, but got %d", expected, result)
    }
}

Step 7: Refactor Add implementation

With the test in place, I restructured Add to handle the multiple inputs.

func Add(numbers ...int) int {
    sum := 0
    for _, num := range numbers {
        sum += num
    }
    return sum
}

The function now takes a variable number of arguments and adds them all up. Running the tests showed that they all passed.

This example demonstrates the importance of robust tests in TDD. By writing comprehensive tests, we can refactor our code with confidence, knowing that any breaking changes will be caught by our tests.

Conclusion

My journey with TDD at Fora has been transformative. As I continued to work with TDD, the learning curve started to flatten, and with it came an appreciation of the benefits. TDD forced me to think about my code in a different way. I had to understand what I wanted my code to do before I started writing it. This led to better design decisions and maintainable code. Moreover, TDD created a safety net of tests. These tests are designed to catch any breaking changes in the future, reducing the likelihood of introducing bugs into the system. This safety net also makes it easier to confidently refactor for improved performance and scalability, knowing that core functionality will be preserved.

The transition was challenging, but the undeniable benefits in terms of code quality, maintainability, and peace of mind have made it worthwhile. I encourage all developers to try out TDD. The initial learning curve may seem steep, but the long-term returns on this investment are substantial.

Grow with us

If you value learning and growth as much as we do, join us!

Join us now