CODEX

Realistic TDD: How I Adapt Test-Driven Development for My Projects

Galangkangin Gotera
CodeX
Published in
10 min readMar 20, 2021

--

Test-Driven Development (TDD) had always been the go-to software development process at my university’s courses that involved developing software (e.g., web programming with Django and Microservices with Java Springboot), so the jargon RED-GREEN-REFACTOR is probably ingrained in every one of my friends by now. Anybody in my class can easily and effortlessly explain the philosophy and methodology of TDD.

But how do you actually code using TDD?

What… do you mean?

In this article, I will discuss the problems I encountered when trying rigid TDD as a young software developer, and how I made some adaptations to make TDD actually faster and safer than normal coding.

Quick Review on TDD

Test-Driven Development is an evolutionary software development process developed by Kent Beck in the late ’90s. In essence, you build your software in small pieces, piece-by-piece, with each piece having three phases:

  • RED: create the test for your next piece. Of course, running the tests now will report an error (aka red) because you had no implementation for this test.
  • GREEN: code the implementation for the previous test until it passed. Now, running the test will report no errors (green).
  • REFACTOR: refactor the code just implemented to make it well structured.
The TDD cycle

The goal of TDD, from my understanding:

  • Tests are always available for every code you made, reducing the possibility of bugs.
  • The software code ends up being well structured where the program flows are easily read and understood.
  • Reduce the time to create tests. Creating many simple tests on the fly is tiring, physically and mentally, then looking at a large codebase and figuring out what to test next.

TDD sounds really good on paper, and it really is! It provides a framework to slowly but surely, develop large structured software, which would cause a billion bugs otherwise.

TDD Examples

Currently, I am working on the login feature for my software project using Golang. One of the vital functionalities is hashing passwords since it is illegal in some countries to store plain text passwords.

Let’s say I’ve got my hashing function down. Cool! Now let's make the code that verifies the hashes. Since its TDD, let's make the tests first:

// Positive test
func TestHashVerifyIsCorrect(t *testing.T) {
hashedString, _ := Hash("fragrance in thaw") assert.Equal(t, true, Verify(hashedString, "fragrance in thaw"))
}
// Negative test
func TestHashVerifyIsWrong(t *testing.T) {
hashedString, _ := Hash("fragrance in thaw")
assert.Equal(t, false, Verify(hashedString, "Fragrance in thaw"))
}

Pretty straightforward. The first test checks if it can verify the same string, the second test checks if it can catch a different string (else, I can “return true” on the code).

The test is done! now we create the implementation

func Verify(hashed string, actual string) bool {  err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(actual))  return err == nil}

The implementation turns out to be quite simple. The library function I use (bcrypt) returns an error when the hash does not match the password. So we check whether the error is nil or not.

That's it! This is how you develop code using TDD. So why is the scrollbar on this article still near the top?

The Tricky

When you realized that the principle was created with professional coders in mind, while my largest “project” was a simple photo storage website. TDD works seamlessly when testing already straightforward functions. Bamboozlement starts to appear when you’re developing with TDD principles as-is:

1. The Test is Wrong

In the software development course, you are required to make a commit for each TDD principle you made. i.e: create test -> commit [RED] -> create code -> commit [GREEN] -> refactor -> commit [REFACTOR]. So, what happens when you commit RED, code the implementation, and then still red?

Here is a case that I actually encountered on my project. Remember the previous test? This is the original implementation of TestHashVerifyIsCorrect

func TestHashVerifyIsCorrect(t *testing.T) {  hashedString, _ := Hash("fragrance in thaw")  assert.Equal(t, true, Verify(hashedString, "fragrances in thaw"))
}

I created this, committed, coded the implementation, run the test, and… test failed. After a considerable amount of debug print statements at my implementation, I realized that the bug was in the tests about fifteen minutes later.

So this means I gotta fix the tests, right?

NO, after RED, you must commit GREEN >:(“ — TDD Gurus, probably

Some may say that I need to put as much care into creating tests as developing the source code. Fair enough, the above mistake is on me. How about another case, then?

Here is the test that I made when developing JWT token generation for the login system.

func TestCreateTokenShouldGiveCorrectPayload(t *testing.T) {  token := GetToken(12, 6, 5)  payloadBase64 := strings.Split(token, ".")[1]  payloadBytes, _ := base64.StdEncoding.DecodeString(payloadBase64)
var payload map[string]interface{}
err := json.Unmarshal(payloadBytes, &payload)
assert.Nil(t, err, "unmarshall must not be error")
payloadUID := payload["uid"].(int64)
payloadRole := payload["role"].(int64)
assert.Equal(t, 12, payloadUID, "payload UID must be equal")
assert.Equal(t, 6, payloadRole, "payload role must be equal")
}

Basically, my test creates a token and verifies whether the payload part of the token matches the intended payload. Go programmers should quickly see a few errors in this test, but the kicker is really beautiful.

My code successfully generates the token. However, the string value of payload is missing the closing curly bracket statement. For example:

{uid: 12, role:6, iss: 123456

I tried printing the whole token and verified the payload by decoding it at jwt.io and www.base64decode.org. Both decoded correctly with the ending bracket. However, somehow my tests kept missing it. I concluded that Go’s base64 decoder might be the culprit and dived the docs and found nothing.

Randomly, I realized that shouldn't base64 end with a ‘=’? I tried to append an equal sign at the end of payloadBase64 And it successfully decoded to the way I wanted. This behavior is really peculiar, and I think many people will stump when creating similar tests.

Oh yeah, this JWT talk brings us to the next tricky.

2. Working with Unfamiliar Entities

My knowledge of JWT is still shaky since I have not worked with JWT much. In fact, I just known that JWT does not sign the payload with the secret key (Thanks, teammate Akbar) a few days before developing the login system.

This problem is worsened because the available JWT library on Go, in my opinion, is counter-intuitive. The most developed library that I found is jwt-go, and boy, it is complex.

I could right away make a test assuming that my GetToken() will return a full JWT token, but after seeing the snippet for generating JWT tokens, I found so many components such as:

  • JWT Standard Claims
  • JWT Custom Claims
  • JWT Token Callback
  • JWT Signing

Since TDD must be piece-by-piece, should I first create tests for each of these components? But I am not sure of their behavior :(. What if these are just internal components, and I don’t need tests for them? AAAA so much uncertainty:”(

How Some Adapt This

These are the issues that I encountered when first starting TDD about two years ago. I asked around about how my colleagues handle these issues, and most of them seem to have the same golden solution:

Code the implementation first, but commit and push the tests first

it’s easy to tell when a large software is built using TDD or this dark side method.

Hopefully, you now know what I'm getting at. We are constantly advocated to do TDD. However, it is quite hard to actually follow the principle. When TDD should’ve been a process that eases software development, it ends up being an obstacle.

How I Adapt This

I first took TDD seriously when developing a LINE chatbot using springboot about a year ago. The project was quite big with a large number of interconnected components. At first, I was planning to go the normal route and write tests at the end. However, I can see where my app was going and said, “Hey, why not TDD?”

Since that project is more liberal and does not dictate you must do rigid TDD, I made minor adaptations to the principle while respecting TDD’s intent. The result was awesome. Although I made over 50+ small unit tests per week, my development actually sped up because I took the best of both worlds:

  • The behavior guidance of TDD: creating small behavioral tests that guides me to develop my large software. When coding, I am still unsure how to overall logic will turn out, but with TDD, I just kept on creating small components that stick together until one day: “huh? the app is done?”
  • The certainty and speed of DDT (the opposite of TDD): The main weakness of TDD is the uncertainty when working with unfamiliar entities. My modification removes that. Sometimes, you just need a little certainly to be fast.

Disclaimer: I by no means state that my adaptation is revolutionary. I also do not state that it is original as it seems like everything has been discovered these days. In fact, this is a simple adaptation. But, it turns TDD into an actually faster and easier software development principle.

I will show my adaptation by answering the issues I presented at the start of the article.

1. The Test is Wrong

This one is easy:

  • Fix the test
  • Run the test. All tests should be successful now since we coded the implementation.
  • Commit RED again on the test.
  • Commit GREEN on the implementation.
  • Ignore all the voices in my head since there is still software to code, and those voices won't help you code :(

I'm kind of leaning towards the dark side on this one. I think this is a necessary evil. When I created a wrong test, there is really nothing else I can do.

2. Working with Unfamiliar entities

This is the big one. I handle this by writing some test-drive code on the method I want to develop before making the test.

I can already hear sirens when writing this.

The test-drive program is usually a “partial” implementation and usually full of log print statements. Its job is for me to get the feel of that entity.

Alright, I'll just skip to an example.

Let's head back to that JWT problem discussed before.

So I’m trying to create a method GetToken using an unfamiliar library (jwt-go). So, before writing the test, I wrote something like this on GetToken()

func GetToken(uid int, role int, expiresInHour int) string {
privateKey := "privatekey"
claims := JwtClaim{
uid,
role,
jwt.StandardClaims{
ExpiresAt: time.Now().Local().Add(time.Hour * time.Duration(expiresInHour)).Unix(),
Issuer: "xxx",
},
}

fmt.Println(claims)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
fmt.Println(token)
jwtToken, _ := token.SignedString(privateKey)
fmt.Println(jwtToken)
return jwtToken}

This is just a minimal modification of the token generation I took from the documentation. I added a lot of print statements to get the feel of each component.

Then, I create a test code to run this method:

func TestCreateTokenShouldGiveCorrectPayload(t *testing.T) { token := GetToken(12, 6, 5) fmt.Println(token)
}

Running this should give a lot of print statements. From here, I know that:

  • The components on the JWT library are pretty light, no need to make separate tests.
  • token.signString() returns the whole token, not just the signed part.
  • I don't need a complicated callback to just sign a token. So again, no need to develop a callback first.

And just like that, the uncertainty is no more. Now, I know that I do not need to create separate components. So now, I can write the actual test for this method. The final product turns out like this:

func TestCreateTokenShouldGiveCorrectPayload(t *testing.T) {  token := GetToken(12, 6, 5)  payloadBase64 := strings.Split(token, ".")[1]
payloadBase64 += "=" //Go's base64 decoder needs = at the end
payloadBytes, _ := base64.StdEncoding.DecodeString(payloadBase64)
var payload map[string]interface{}
err := json.Unmarshal(payloadBytes, &payload)
assert.Nil(t, err, "unmarshall must not be error")

payloadUID := payload["uid"].(float64)
payloadRole := payload["role"].(float64)
assert.Equal(t, 12, int(payloadUID), "payload UID must be equal")
assert.Equal(t, 6, int(payloadRole), "payload role must be equal")
}

Pretty straightforward right? generate the token, get the payload part, decode it, and finally check if it matches the intended payload. Overall, it took me about twenty minutes to implement all of this (from writing test-drive, to writing actual test, to writing the actual implementation).

And that's about it! that's my simple adaptation to TDD. Whenever I'm working with unfamiliar entities, I just write some test-drive code to get the feeling and then get back to TDD.

My TDD in Action

To clarify, I rarely do what I said previously, only when I'm working with unfamiliar stuff. But, having the option to do so is really nice.

When I don't, it’s the textbook RED-GREEN-REFACTOR. Create test -> implement code -> refactor. Here is a sample pipeline from my current project where I do TDD to develop the whole login service:

A few things to notice first:

  • Note at the bottom there are two RED tags. That's just like what I said, committing double red when the test has bugs.
  • CHORES tag at the top is a special tag for changing config files (in go, it’s go.mod)

The message is truncated, so here is the development sequence:

  1. Create tests for when the user’s email is not found in DB
  2. implement it
  3. Create tests for when the user entered an invalid password
  4. implement it
  5. Create tests for when the user did a successful login
  6. implement it

Easy as one two three.

Conclusion

I cannot imagine building my large projects without TDD. TDD is an amazing framework, but the rigidness made some people scared. By writing a simple test-drive whenever I felt uncertain, I can develop applications seamlessly, while still achieving TDD’s principle:

  • There is always a test for each developed code.
  • Software is built in small incremental pieces, where each piece is well tested.

And that’s about it. Hope by reading this article, you will see past the rigidness and give TDD a try.

For me, TDD is not a rule, but a guide.

References

--

--