This text is an introduction to TDD that shows a step-by-step attempt to solve a real programming task - the Coding Challenge #12 - Build Your Own URL Shortener.

In short, we will build a web application that accepts a custom url under /api/shorten?url=<url>. Such a call will return a short key that can be used later when the user browses to /<key>. This action will redirect the user to the original <url>.

Steps described below are my actual thought-process, a TDD journal. All code snippets in this article show a “work in progress” and are not intended to be read out-of-context. Bear in mind it’s just a tutorial, so the code is not production-grade quality.

Why TDD

Test Driven Development is a proven, professional practice of Software Design. It’s not a practice of testing, although as a side effect we do create a safety-net built of tests around the code.

TDD fits to some problems and to some it doesn’t. Nevertheless it’s a tool worth knowing.

First steps

We start with code, right? Well, no… We are going to start with writing down requirements. The more we gather the easier it will be to nail down the design (although it’s necessary to properly balance the up-front-design).

Since “BDD is TDD done right”, we will use BDD.

Let’s start with the most obvious ones.

WHEN
* User sends the url https://go.dev to the system
THEN
* A url is returned that ends with a custom key
* The key in the url has 6 characters
  
GIVEN
* User sends a url https://go.dev to the system
* System returned a url with a key X
WHEN
* User sends a url https://go.dev to the system again
THEN
* A reused Url with the X key is returned
  
GIVEN
* User sends a url https://go.dev to the system
* System returned a url with a key X
WHEN
* User sends Url https://gobyexample.com/ to the system
THEN
* A different Url than X is returned
  
GIVEN
* Url https://go.dev is sent to the system
* System returned a url with a key X
WHEN
* User browsed to the url with the key X
THEN
* System redirects user to the page https://go.dev  

In theory we could write these requirements as plain text in a standard, non-example form. The BDD form is not always easier. But the benefit of this approach is that it allows us to translate the requirements directly to tests. That is because this style forces you to operate on input signals and observable behaviors only.

I think we’re good with these requirements for now.

First test

Since we want to do TDD here, we’ll start with writing the test. But before the first test can be written we need to do some scaffolding. I use Go (golang), but similar stuff can be done in any other language.

Go’s naming convention for tests is not BDD friendly. Nevertheless we can leverage it a bit.

// file: bdd_test.go
package main
   
import "testing"
   
func Test_Should_Generate_Url_With_Key(t *testing.T) {
	// WHEN
	// * User sends the url https://go.dev/ to the system
	// THEN
	// * A url is returned that ends with a custom key
	// * The key in the url has 6 characters
}

Yeah, that’s just our requirement as comments in code. We will build from there. Since our scaffolding for tests is not there yet, I can only imagine how it may look like - I envision it like this:

// file: bdd_test.go
package main
   
import (
	"net/http"
	"strings"
	"testing"
   
	"github.com/stretchr/testify/assert"
)
   
func Test_Should_Generate_Url_With_Key(t *testing.T) {
	// WHEN
	// * User sends the url https://go.dev/ to the system
	response := client.Shorten("https://go.dev/")
   
	// THEN
	// * A url is returned that ends with a custom key
	assert.Contains(t, response.Content, client.url)
	// * The key in the url has 6 characters
	key := strings.ReplaceAll(response.Content, client.url, "")
	assert.Len(key, 6)
}

This code doesn’t compile and be wary that this code may (in fact it will) change later a lot. For the time being I fancy some kind of a “client” to call our module-under-test and that we’re going to build some nice wrappers, so tests can be read like an executable spec, not like a low level code.

To make the code compile I’ve prepared the following components (on the bottom of our test file).

// file: bdd_test.go
   
type MyClient struct {
	url string
}
   
func (c MyClient) Shorten(t *testing.T, address string) MyResponse {
	targetUrl := fmt.Sprintf("%s/api/shorten?url=%s", c.url, address)
   
	resp, err := http.Post(targetUrl, "application/json", nil)
	if err != nil {
		t.Fatalf("MyClient: error making http request: %v", err)
	}
	defer resp.Body.Close()
   
	body, err := io.ReadAll(resp.Body)
   
	if err != nil {
		t.Fatalf("NewMyResponse: error reading response body: %v", err)
	}
   
	bodyText := string(body)
   
	if resp.StatusCode != 200 {
		t.Fatalf("Failed resp: %v: %v", resp.Status, string(bodyText))
	}
   
	return MyResponse{
		Content: bodyText,
	}
}
   
type MyResponse struct {
	Content string
}

The implementation of these helpers forced me to change the test code a bit:

// file: bdd_test.go
func Test_Should_Generate_Url_With_Key(t *testing.T) {
	client := MyClient{url: "http://whathost???"}
	// WHEN
	// * User sends the url https://go.dev/ to the system
	response := client.Shorten(t, "https://go.dev/")
   
	// THEN
	// * A url is returned that ends with a custom key
	assert.Contains(t, response.Content, client.url)
	// * The key in the url has 6 characters
	key := strings.ReplaceAll(response.Content, client.url, "")
	assert.Len(t, key, 6)
}

I don’t like the client variable to be created in the test itself, but let’s leave it here for now.

In this moment we have the test code that compiles and when run it fails with a message:

--- FAIL: Test_Should_Generate_Url_With_Key (1.11s)
    /bdd_test.go:41: MyClient: error making http request: Post "http://whathost???/api/shorten?url=": dial tcp: lookup whathost on 172.23.224.1:53: no such host

So it seems that the test is OK. It makes a POST request to some address and validates the response.

First code

Let’s scaffold a web server in golang.

// file: main.go
package main
   
import (
	"fmt"
	"log"
	"net/http"
)
   
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/shorten", ShortenHandler)
   
	err := http.ListenAndServe("127.0.0.1:8080", mux)
	log.Fatal(err)
}
   
func ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	key := "123456"
	result := fmt.Sprintf("http://127.0.0.1:8080/%s", key)
	resp.Write([]byte(result))
}

That’s it. It’s a quite straightforward implementation, but it does the job. You can run it in a terminal and see it working. In fact it should satisfy our first test! Let’s just configure the test to use the real address.

// file: bdd_test.go
func Test_Should_Generate_Url_With_Key(t *testing.T) {
	client := MyClient{url: "http://127.0.0.1:8080/"}

Now, if you run main.go in the terminal and then you execute the test, it will pass!

So we can claim our first success, right? In TDD we try to implement the simplest solution that makes the test pass. It seems like cheating but it’s not. Systems have thousands of angles at which we can observe them, and a single test validates only one of them. From our test’s point of view the app works. It’s not a problem, because we still need to translate more requirements into tests. Fake solutions won’t stand. On the other hand this approach should lead to simpler designs with less over-engineering.

Let me reiterate it. In Test Driven Development we write one or more tests first. Then we write the simplest possible code that makes the test(s) pass. Probably you are pro enough to spot places that are going to fail during the execution, but that’s fine! These places should be ideas for next tests.

Before we move further, we should look at another practice of TDD, that is the “red-green-refactor” cycle. The code works, but it’s not “right” (as XP says “make it work, make it right, make it fast”). The biggest benefit of TDD is that during the refactor step we can feel safe to shovel the code around, because tests will ensure we haven’t broken anything. Well, that is true as long as you stick to the Chicago School of TDD and avoid mocks. I covered that topic in one of my previous articles (Unit Tests - from waste to asset).

In our case we know that the current implementation is just a stub, so there’s no point in polishing it. But there’s at least one code smell that I want to fix right away. The host address is repeated. A const would do, but experience tells me that it’s much better to create a struct that will gather data required by the running service.

// file: main.go
func main() {
	srv := MyService{
		url: "127.0.0.1:8080",
	}
   
	mux := http.NewServeMux()
	mux.HandleFunc("/api/shorten", srv.ShortenHandler)
   
	err := http.ListenAndServe(srv.url, mux)
	log.Fatal(err)
}
   
type MyService struct {
	url string
}
   
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	key := "123456"
	result := fmt.Sprintf("%s/%s", srv.url, key)
	resp.Write([]byte(result))
}

Let’s run the test just for sake of it.

--- FAIL: Test_Should_Generate_Url_With_Key (0.00s)
   /bdd_test.go:21:  
        	Error Trace:	/bdd_test.go:21
        	Error:      	"127.0.0.1:8080/123456" does not contain "http://127.0.0.1:8080/"
        	Test:       	Test_Should_Generate_Url_With_Key

Wow, an easy refactoring, but it introduced a first bug! In the ShortenHandler() I’ve replaced the whole Url with a variable, but I should have left the http scheme in place.

Let’s fix it like this:

// file: main.go
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	key := "123456"
	result := fmt.Sprintf("http://%s/%s", srv.url, key)
	resp.Write([]byte(result))
}

The hosting scheme in a real prod app should be configurable, but for this tutorial app I’m not planning on hooking up https.

And now the test passes!

Before next test

If you think that starting the app in the terminal before running the tests is quite lame, you’re right. The test suite should run the code itself. In golang it can be done in the func TestMain(m *testing.M) { }. Other languages and frameworks for sure have their ways to do it.

Following code will be responsible for setting up our application for testing purposes:

// file: bdd_test.go
var client MyClient
   
func TestMain(m *testing.M) {
	mySrv, mux := buildServer("")
	httpSrv := httptest.NewServer(mux)
   
	mySrv.url = httpSrv.URL
   
	client = MyClient{
		client: httpSrv.Client(),
		url:    httpSrv.URL + "/",
	}
   
	m.Run()
   
	httpSrv.Close()
}

It’s executed once for all tests, starts a new instance of the test server and configures our MyClient instance. Before we can use this code there have to be some changes in other parts of the application. First of all MyClient is not created in the test anymore and its definition had to be change to:

// file: bdd_test.go
type MyClient struct {
	url    string
	client *http.Client
}

Also the way we invoke the Post method is changed to:

    // file: bdd_test.go
    resp, err := c.client.Post(targetUrl, "application/json", nil)

And the biggest change was in the main.go, so the app’s scaffolding. To be able to use buildServer() I had to split the server’s definition from its execution.

So now it looks like that:

// file: main.go
func main() {
	srv, mux := buildServer("http://127.0.0.1:8080")
   
	srvUrl, _ := url.Parse(srv.url)
   
	err := http.ListenAndServe(srvUrl.Host, mux)
	log.Fatal(err)
}
   
func buildServer(url string) (*MyService, *http.ServeMux) {
	srv := &MyService{
		url: url,
	}
   
	mux := http.NewServeMux()
	mux.HandleFunc("/api/shorten", srv.ShortenHandler)
   
	return srv, mux
}

And since we pass url with http scheme to the buildServer() (it was required by the httptest) I had to change ShortenHandler() method a bit:

// file: main.go
	result := fmt.Sprintf("%s/%s", srv.url, key)

Once all these changes are done we can run tests with just the go test command without running the app in the terminal. It’s worth noting that since now we don’t even know on which port the test server runs. That’s because httptest will use a different port each time. That’s the reason tests have to use httpSrv.Client() to talk to the test server.

We can finish our second round of refactoring. Now we’re ready to write the next test!

Second test

It’s very easy to start with the next test. Let’s just copy-paste the requirements and convert them to comments.

// bdd_test.go
func Test_Should_Reuse_Same_Key(t *testing.T) {
	// GIVEN
	// * User sends a url https://go.dev to the system
	// * System returned a url with a key X
	// WHEN
	// * User sends a url https://go.dev to the system again
	// THEN
	// * A reused Url with the X key is returned
}

By the way, normally I would remove the “comments with requirement text” from the test code once it’s finished. The test code (that uses a DSL layer to invoke the module under test) is readable enough. I’m leaving them in this tutorial to not make it any longer.

This time it’s very easy to fill the test with a real code:

// file: bdd_test.go
func Test_Should_Reuse_Same_Key(t *testing.T) {
	// GIVEN
	// * User sends a url https://go.dev to the system
	firstResp := client.Shorten(t, "https://go.dev/")
	// * System returned a url with a key X
	firstKey := strings.ReplaceAll(firstResp.Content, client.url, "")
   
	// WHEN
	// * User sends a url https://go.dev to the system again
	secondResp := client.Shorten(t, "https://go.dev/")
   
	// THEN
	// * A reused Url with the X key is returned
	secondKey := strings.ReplaceAll(secondResp.Content, client.url, "")
	assert.Equal(t, firstKey, secondKey)
}

And - what a surprise - the test passes! Well, it’s no surprise since the server returns the same key no matter what url you send to it.

Does it mean that this test is pointless? Not at all. It does cover one of the requirements and it will protect the required behavior of the system as the code evolves or with every further refactoring!

Third test

Our next requirement will not be met right away for sure:

// file: bdd_test.go
func Test_Should_Generate_Url_With_Different_Key(t *testing.T) {
	// GIVEN
	// * User sends a url https://go.dev to the system
	// * System returned a url with a key X
	// WHEN
	// * User sends Url https://gobyexample.com/ to the system
	// THEN
	// * A different Url than X is returned
}

Converting it to code is also easy:

// file: bdd_test.go
func Test_Should_Generate_Url_With_Different_Key(t *testing.T) {
	// GIVEN
	// * User sends a url https://go.dev to the system
	firstResp := client.Shorten(t, "https://go.dev/")
	// * System returned a url with a key X
	firstKey := strings.ReplaceAll(firstResp.Content, client.url, "")
   
	// WHEN
	// * User sends Url https://gobyexample.com/ to the system
	secondResp := client.Shorten(t, "https://gobyexample.com/")
   
	// THEN
	// * A different Url than X is returned
	secondKey := strings.ReplaceAll(secondResp.Content, client.url, "")
	assert.NotEqual(t, firstKey, secondKey)
}

And just as expected, the test fails:

--- FAIL: Test_Should_Generate_Url_With_Different_Key (0.00s)
    /bdd_test.go:76:  
        	Error Trace:	/bdd_test.go:76
        	Error:      	Should not be: "123456"
        	Test:       	Test_Should_Generate_Url_With_Different_Key

Coding 3rd requirement

The task we have right now is to implement the feature of the web app that returns a real shortened url. At the same time we must not break the two previously implemented behaviors.

Before we can start coding we must do a high level design of the new feature (the code itself is a low level design. Yes, the code is a design. The building phase means the compilation).

Our design concerns are: does it need to be fast? What collision rate is acceptable? Are we going to check for collisions? And the most important question: how are we going to know if we have the right answers for the questions above?

The best approach is in fact very simple - we should implement the easiest solution that satisfies known requirements and at the same time keep the door open for future modifications. There’s no point in over-engineering or preparing for a scenario which is not guaranteed to happen.

Having said that, I made a decision that performance is not an issue, collision rate is not a problem and we won’t check for it as long as our tests are not flaky.

These requirements should be met by a standard sha256 hash truncated to 6 chars.

// file: main.go
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	query := req.URL.Query()
	inputUrl := query.Get("url")
   
	h := sha256.New()
	h.Write([]byte(inputUrl))
   
	hash := base64.URLEncoding.EncodeToString(h.Sum(nil))
	key := hash[:6]
   
	result := fmt.Sprintf("%s/%s", srv.url, key)
	resp.Write([]byte(result))
}

As you can see I’ve implemented one “unspoken” requirement that the key will be “url friendly”. I.e. it cannot contain characters that can break the url navigation (e.g. /).

I’ve also discovered a new requirement, that the empty or missing query parameter should not break the code! Later a dedicated test will have to be added to cover it.

Let’s check our tests.

All of them pass!

The requirement is implemented and the test is guarding it. It means we are safe to change our “make it work” hat and to put our “refactoring” hat on (or “make it right” hat if you will) and do some tidings.

First of all there’s a design rule “don’t mix code on different levels of abstractions”. And that’s exactly what I’ve done when I’ve put the hash computation into our “business level” handler.

Let’s refactor it into a function. In the future maybe it will go into some kind of “key manipulation” package.

// file: main.go
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	query := req.URL.Query()
	inputUrl := query.Get("url")
   
	key := generateKey(inputUrl)
   
	result := fmt.Sprintf("%s/%s", srv.url, key)
	resp.Write([]byte(result))
}
   
func generateKey(url string) string {
	h := sha256.New()
	h.Write([]byte(url))
   
	hash := base64.URLEncoding.EncodeToString(h.Sum(nil))
   
	return hash[:6]
}

4th test

Our next test will be a bit tricky. Let’s start with comments:

// file: bdd_test.go
func Test_Should_Redirect_to_original_url(t *testing.T) {
	// * Url https://go.dev is sent to the system
	// * System returned a url with a key X
	// WHEN
	// * User browsed to the url with the key X
	// THEN
	// * System redirects user to the page https://go.dev
}

You can see that we don’t have enough DSL operations to cover all the steps. First let’s imagine what it could be:

// file: bdd_test.go
func Test_Should_Redirect_to_original_url(t *testing.T) {
	// GIVEN
	// * Url https://go.dev is sent to the system
	// * System returned a url with a key X
	shortenResp := client.Shorten(t, "https://go.dev/")
   
	// WHEN
	// * User browsed to the url with the key X
	browseResp := client.BrowseTo(t, shortenResp.Content)
   
	// THEN
	// * System redirects user to the page https://go.dev
	assert.Equal(t, http.StatusPermanentRedirect, browseResp.HttpStatus)
	assert.Equal(t, "https://go.dev/", browseResp.Headers.Get("Location"))
}

To make the test compile we need the BrowseTo() method in MyClient, but to implement it without ugly copy-paste, we need to refactor a part of Shorten() to a new method. And we have to extend MyResponse with additional fields:

// file: bdd_test.go
func (c MyClient) Shorten(t *testing.T, address string) MyResponse {
	targetUrl := fmt.Sprintf("%s/api/shorten?url=%s", c.url, address)
   
	resp, err := c.client.Post(targetUrl, "application/json", nil)
	if err != nil {
		t.Fatalf("MyClient: error making http request: %v", err)
	}
	defer resp.Body.Close()
   
	myResp := readResponse(t, resp)
   
	if resp.StatusCode != 200 {
		t.Fatalf("Failed resp: %v: %v", resp.Status, string(myResp.Content))
	}
   
	return myResp
}
   
type MyResponse struct {
	Content    string
	HttpStatus int
	Headers    http.Header
}
   
func readResponse(t *testing.T, resp *http.Response) MyResponse {
	body, err := io.ReadAll(resp.Body)
   
	if err != nil {
		t.Fatalf("NewMyResponse: error reading response body: %v", err)
	}
   
	bodyText := string(body)
   
	return MyResponse{
		Content:    bodyText,
		HttpStatus: resp.StatusCode,
		Headers:    resp.Header,
	}
}

Implementation of the BrowseTo() should be quite straightforward now:

// file: bdd_test.go
func (c MyClient) BrowseTo(t *testing.T, address string) MyResponse {
	resp, err := c.client.Get(address)
	if err != nil {
		t.Fatalf("MyClient: error making http request: %v", err)
	}
	defer resp.Body.Close()
   
	return readResponse(t, resp)
}

Let’s run the test:

--- FAIL: Test_Should_Redirect_to_original_url (0.00s)
    /bdd_test.go:97:  
        	Error Trace:	/bdd_test.go:97
        	Error:      	Not equal:  
        	            	expected: 308
        	            	actual  : 404
        	Test:       	Test_Should_Redirect_to_original_url

Well, it seems fine. The test fails with http status 404, because the server doesn’t have such an endpoint.

Let’s move on.

Implementing 4th requirement

The easiest way to make the test pass is to fake the response with a constant response.

// file: main.go
   
func (srv *MyService) RedirectHandler(resp http.ResponseWriter, req *http.Request) {
	resp.Header().Add("location", "https://go.dev/")
	resp.WriteHeader(http.StatusPermanentRedirect)
}

And to hook it up we need to extend buildServer() with a new HandleFunc, so it looks likes this:

// file: main.go
  
	mux := http.NewServeMux()
	mux.HandleFunc("/", srv.RedirectHandler)
	mux.HandleFunc("/api/shorten", srv.ShortenHandler)

This code means that all requests that don’t fall into /api/shorten will be handled by our new RedirectHandler.

Our job should be done here. Let’s run our new test:

--- FAIL: Test_Should_Redirect_to_original_url (0.46s)
    /bdd_test.go:97:  
        	Error Trace:	/bdd_test.go:97
        	Error:      	Not equal:  
        	            	expected: 308
        	            	actual  : 200
        	Test:       	Test_Should_Redirect_to_original_url

Well, I didn’t see that coming… Instead of the redirect we received the http status 200 (OK)! And I can bet that if we had investigated the response’s body in the debug mode, we would see a valid html being returned.

In hindsight it’s quite obvious that most http clients handle redirects automatically. So what’s happened is that our server did return a valid redirect and Go’s http.Client just followed it and returned the real content of https://go.dev/!

Since it’s just a test and we want to keep it local, let’s force our http.Client to return the redirects, not follow them. Fortunately there’s an easy way to do it. We need to modify the top of our TestMain() method and add CheckRedirect callback:

// file: test_int.go
func TestMain(m *testing.M) {
	mySrv, mux := buildServer("")
	httpSrv := httptest.NewServer(mux)
	httpSrv.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if !strings.HasPrefix(req.URL.String(), httpSrv.URL) {
			return http.ErrUseLastResponse
		}
		return nil
	}

Let’s run all tests:

ok  	go6urlshortener	0.005s	

5th test

Of course we can’t leave our code like that. A permanent redirect to https://go.dev/ is not a valid behavior. The problem is I haven’t specified any requirement at the beginning of our tutorial that would define that behavior. We need a new requirement:

GIVEN
* Url https://go.dev is sent to the system
* System returned key X
* Url https://gobyexample.com/ is sent to the system
* System returned key Y
WHEN
* User browsed to the url with the key Y
THEN
* System redirects user to the page https://gobyexample.com/

Filling it with code is quite straightforward:

// file: bdd_test.go
func Test_Should_Redirect_second_to_original_url(t *testing.T) {
	// GIVEN
	// * Url https://go.dev is sent to the system
	// * System returned key X
	client.Shorten(t, "https://go.dev/")
	// * Url https://gobyexample.com/ is sent to the system
	// * System returned key Y
	shortenResp := client.Shorten(t, "https://gobyexample.com/")
   
	// WHEN
	// * User browsed to the url with the key Y
	browseResp := client.BrowseTo(t, shortenResp.Content)
   
	// THEN
	// * System redirects user to the page https://gobyexample.com/
	assert.Equal(t, http.StatusPermanentRedirect, browseResp.HttpStatus)
	assert.Equal(t, "https://gobyexample.com/", browseResp.Headers.Get("Location"))
}

5th requirement challenge

The latest requirement is the most challenging one, because it requires us to save the mythical state. In particular the generated keys should be available for the RedirectHandler() handler when we try to redirect to the original url.

Once again we have to decide how to design the app. Do we store the state in memory? It would work, but what if someone runs this tutorial on the production and the server reboots? So maybe file storage will do? It’s not a good idea, because every pod on k8s has an isolated disk storage (or doesn’t have any) and it’s ephemeral. So maybe Redis with persistence on?

The best approach again is to implement the simplest solution first (so, the in memory storage), but to keep the options open for the future (the Protected Variations pattern).

Let’s start with a simple dict that will be available through the MyService struct. I already have a hunch I will need to refactor that, nevertheless we should just “make it work” first:

// main.go
type MyService struct {
	url   string
	store InMemoryStore
}
   
type InMemoryStore struct {
	hashes map[string]string
}

To initialize the new field we just have to extend the buildServer().

// main.go
func buildServer(url string) (*MyService, *http.ServeMux) {
	srv := &MyService{
		url: url,
		store: InMemoryStore{
			hashes: make(map[string]string),
		},
	}

Having the dict here will allow us to store the generated keys in ShortenHandler() quite easily:

// file: main.go
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	// (…)
   
	key := generateKey(inputUrl)
   
	srv.store.hashes[key] = inputUrl
   
	result := fmt.Sprintf("%s/%s", srv.url, key)
	// (…)

There’s a bit more labor in the RedirectHandler():

// file: main.go
func (srv *MyService) RedirectHandler(resp http.ResponseWriter, req *http.Request) {
	key := strings.TrimPrefix(req.URL.Path, "/")
   
	location := srv.store.hashes[key]
   
	resp.Header().Add("location", location)
	resp.WriteHeader(http.StatusPermanentRedirect)
}

With that code in place we can see that the test started to pass!

But that’s just half way through. Now we need to refactor the code. As I mentioned above, I need to make sure that any future requirements for the Store won’t make the code explode. In the end, why should handlers care if the Store is implemented as in-memory dict or as a Redis database? That’s again a different level of abstraction. Cohesion (the good coupling) would also suffer if we had placed not related things together.

Let’s apply the “Dependency Inversion Principle”. I would start with abstracting the Store the Go way, so via an interface on consumer side:

// file: main.go
type MyService struct {
	url   string
	store Store
}
   
type Store interface {
	Get(string) string
	Set(string, string)
}

With that abstraction in place we can change the RedirectHandler() as follows:

// file: main.go
func (srv *MyService) RedirectHandler(resp http.ResponseWriter, req *http.Request) {
	// (...)
	location := srv.store.Get(key)
	// (...)

And the ShortenHandler() accordingly:

// file: main.go
func (srv *MyService) ShortenHandler(resp http.ResponseWriter, req *http.Request) {
	// (...)
	srv.store.Set(key, inputUrl)
	// (...)

The real implementation will go to a separate (sub)package. It doesn’t do much more there than the original dict did:

//file: inmemory/store.go
   
package inmemory
   
type Store struct {
    hashes map[string]string
}
   
func (s *Store) Set(key, location string) {
    s.hashes[key] = location
}
   
func (s *Store) Get(key string) string {
    return s.hashes[key]
}

To hook up the inmemory.Store with our server we need to modify the buildServer() once again. That’s the main “setup” method, invoked directly by main(). In Go language that’s the usual place when Dependency Injection is configured.

// file: main.go
func buildServer(url string) (*MyService, *http.ServeMux) {
	srv := &MyService{
		url:   url,
		store: &inmemory.Store{},
	}
	// (...)

That’s about it. The refactoring is finished. We can run the tests and… see them failing:

http: panic serving 127.0.0.1:48512: assignment to entry in nil map

Of course, a simple mistake again. Previously the map was initialized with make() in buildServer() and it was changed to the initialization of inmemory.Store(), which leaves fields empty.

That’s a good reason to use a constructor pattern.

// file: inmemory/store.go
func NewStore() *Store {
	return &Store{
		hashes: make(map[string]string),
	}
}
// file: main.go
func buildServer(url string) (*MyService, *http.ServeMux) {
	srv := &MyService{
		url:   url,
		store: inmemory.NewStore(),
	}

With these fixes all tests are passing!

Not the end

Reaching the last paragraphs of this text doesn’t mean that this code is ready. We haven’t covered any negative scenarios yet (e.g. http 400 error for missing url parameter or for trying to redirect to a non-existing key). And despite refactorings, the code is still not split into files as I would like it to be.

But every article has to finish somewhere. I just hope the idea of TDD was conveyed.

There are some some closing thoughts though:

  • Was it a real TDD? Usually dev tests start inside the code, e.g. by invoking the handler via code, not via the HTTP stack. (Maybe this tutorial looked more like the ATDD practice? I hope not). But does it really matter? With Go’s built-in httptest.NewServer() it was so easy to spin up a lightweight, in-process server, that in my opinion the pros overcame cons.
  • I wanted to use the Chicago School of TDD (or Classical as some call it), so the tests are “social”. It means that all collaborators of the code components are used together and stabs/mocks are used only to fake the external collaborators (e.g. database). That’s a far better approach than the London School (Mockist School) which introduces a lot of mocks around code components that prevent any refactorings.
  • The features that still wait to be implemented are: handling the HTTP DELETE, the Frontend, covering all error paths, the persistent store. I leave it as an exercise for the reader.

Call to action

Learn TDD. Even better, learn TDD while learning Go.


Andrzej Nowik/2023.12.03v1.2
.