DEV Community

Eke Enyinnaya Diala
Eke Enyinnaya Diala

Posted on

TDD and Software Design

Last week I was watching a video on YouTube. It was a conversation about Clean Code with Primagen, Teej, Casey Muratori, and Carson Gross. The conversation touched test driven development and they offered their opinions which I won't rehash here. Watch the video, it is as informative as it is entertaining.

After watching the video, I went back to reading "The Go Programming Language". I am fairly proficient in Go but I want to have an even better understanding of the language. In Chapter 4, there was a function called dedup. Please keep in mind that I am not criticising this bit of code, it works and it was used by the author for illustrative purposes:

func main() {
    seen := make(map[string]bool) // a set of strings
    input := bufio.NewScanner(os.Stdin)
    for input.Scan() {
        line := input.Text()
        if !seen[line] {
            seen[line] = true
            fmt.Println(line)
        }
    }

    if err := input.Err(); err != nil {
        fmt.Fprintf(os.Stderr, "dedup: %v\n", err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

My policy around reading programming texts is to write out every bit of code in it so I set about rewriting this function but I had one tiny problem. I was tired of remembering how to run each function. This code is in a main function which means I have to remember what folder it is in to run it. I have been doing this for 4 chapters now and I am just tired of typing out the file path. I decided to instead write tests for it so I just need to remember the test name. Another upside to using tests to test code is the ease of adding more test cases.

This decision exposed me to one of the interesting positives of test driven development: Code Modularity. Ideally, we want our software components to be independent modules that we can compose in different ways to achieve our business goals. To test the code above is going to be difficult as it is tightly coupled with standard input and error; you have to mock os.Stdin or do some other shenanigan to capture what is inside it. Writing the test for the function however forced me to rethink the structure of the function. What does this function really do and does it really care about os.Stdin and os.Stderr? The answer to the second question is an emphatic NO. The answer to the first question is we just want something to read from and another thing to write to, ergo, a reader and a writer. See alternative implementation:

func dedup(r io.Reader, w io.Writer) error {
    seen := make(map[string]bool)
    scanner := bufio.NewScanner(r)

    for scanner.Scan() {
        line := scanner.Text()
        if !seen[line] {
            seen[line] = true
            fmt.Fprint(w, line)
        }
    }

    return scanner.Err()
}
Enter fullscreen mode Exit fullscreen mode

Same functionality but decoupled from its dependencies using interfaces and a bit less fragile. It is self contained and can be used with any reader or writer. Making changes to the code can be done with a bit more confidence plus we do not have to manually run the code to see check for regression. Testing it becomes easy because you can make any reader and writer and it works:

func TestDedup(t *testing.T) {
    data := "hello"
    r := &bytes.Buffer{}
    r.WriteString(data)
    r.WriteByte('\n')
    r.WriteString(data)

    w := &bytes.Buffer{}
    if err := dedup(r, w); err != nil {
        t.Fatalf("dedup() expected nil error, got %v", err)
    }

    if count := strings.Count(w.String(), "hello"); count != 1 {
        t.Errorf("dedup() writes %s, want %s", w.String(), data)
    }
}
Enter fullscreen mode Exit fullscreen mode

In my opinion, this is one of those situations where TDD shines. The process of thinking about how to test a function facilitates better design decisions. As always, there are no right or wrong answers regarding TDD, just trade offs and use cases that fit or not.

Thanks for reading.

Top comments (0)