Software and Tests

Binary Systems

Hugo Corbucci
Staff Engineer for Customer Experience (CX) at DigitalOcean

DigitalOcean logo

Largest non-billions USD founded cloud

Founded in 2011 in NYC

Worldwide operations (13 datacenters in 7 countries)

Remote first company headquartered in NYC

A world map with some of the DO employee locations (and Hugo in Sao Paulo, Brazil)

DigitalOcean logo

500K active customers

80M HTTP requests/day

1.7M active droplets + 90PiB storage

500 deploys/day

1200 services

USD 4.5 Billion valuation

200 Engineers in a 600 people company

Not this Binary system

There are only 10 types of people in the world: Those who understand binary and those who don't.
Source: https://medium.com/@aisatou/binary-its-as-easy-as-01-10-11-f2e3334d92a4

This Binary system

A binary star system that is in balance and each star revolves around each other.
By User:Zhatt - Own work, Public Domain, https://commons.wikimedia.org/w/index.php?curid=280053

The context:

Micro-services

Small request/response services with (or without) dependencies

DigitalOcean's overall architecture

Few (3) legacy Ruby on Rails monolithic applications

Migrating to Golang microservices since 2015 using gRPC/protobufs

Different datastores depending on the needs highlighting MySQL and etcd

The test pyramid:

VERY MANY unit tests

Fair # of integration tests

Some end to end tests

Why does the pyramid exist?

Unit tests are fast
but don't catch integration problems

Integration tests are slower and harder to debug
but they catch more problems

End to end tests verify what the user sees
but are slower and less reliable

The problems that come with the pyramid:

Unit tests that are very simple and don't really catch real problems

Integration tests that repeat a lot of setup/verifications from unit tests

End to end tests only for happy paths that are most commonly exercised

The solution:

Dependency Injection

THE END!

The implementation of this idea is left as an exercise to the audience

Dependency Injection

Test is code

that depends on the code under test

to output 👍 or 👎

A go http webserver example

https://github.com/hugocorbucci/multitest-go-example

              
                func main() {
                  s := server.NewHTTPServer()
                  ll := log.New(os.Stdout, "HTTP", 0)
                  addr := net.JoinHostPort("", "8080")

                  if err := http.ListenAndServe(addr, s); err != nil {
                    ll.Fatal("HTTP(s) server failed")
                  }
                }
              
            
cmd/server/main.go
              
                type Server struct {
                  *mux.Router
                }

                func NewHTTPServer() *Server {
                  r := mux.NewRouter()
                  r.HandleFunc("/", helloWorld).Methods(http.MethodGet)
                
                  return &Server{r}
                }
                
                func helloWorld(w http.ResponseWriter, req *http.Request) {
                  msg := "Hello, world"
                  fmt.Println("Responding request")
                  w.Write([]byte(msg))
                }
              
            
internal/server/http.go
https://github.com/gorilla/mux
            
                func TestHelloWord(t *testing.T) {
                  w := httptest.NewRecorder()
                
                  helloWorld(w, nil)
                
                  require.Equal(t, http.StatusOK, w.Code, "expected status code to match")
                  body := string(w.Body.Bytes())
                  assert.Equal(t, "Hello, world", body, "expected body to match")
                }
            
          
internal/server/http_internal_test.go
https://github.com/stretchr/testify

This doesn't test the integration with the mux library (or the NewServer code)

            
                func TestHomeReturnsHelloWorldLocal(t *testing.T) {
                  s := server.NewHTTPServer()

                  w := httptest.NewRecorder()
                  httpReq := httptest.NewRequest(http.MethodGet, "/", nil)
                  s.ServeHTTP(w, httpReq)
                
                  require.Equal(t, http.StatusOK, w.Code, "expected status code to match for req %+v", httpReq)
                  body := string(w.Body.Bytes())
                  assert.Equal(t, "Hello, world", body, "expected body to match")
                }
            
          
internal/server/http_test.go

This is better, but doesn't handle HTTP (de)serialization

            
              func TestHomeReturnsHelloWorld(t *testing.T) {
                s := server.NewHTTPServer()
                listener, err := net.Listen("tcp", "localhost:0")
                if err != nil {
                  t.Fatalf("could not listen for HTTP requests: %+v", err)
                }
                baseURL := "http://" + listener.Addr().String()
                srvr := http.Server{Addr: baseURL, Handler: s}
                go srvr.Serve(listener)
                defer func() {
                  if err := srvr.Shutdown(context.Background()); err != nil {
                    t.Logf("could not shutdown http server: %+v", err)
                  }
                }()
                httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                require.NoError(t, err, "could not create GET / request")
                resp, err := http.DefaultClient.Do(httpReq)
                require.NoError(t, err, "error making request %+v", httpReq)
                require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                body, err := readBodyFrom(resp)
                require.NoError(t, err, "unexpected error reading response body")
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
            
          
internal/server/http_test.go

Now everything is tested: bringing the server up, executing the code and closing it all

            
              func TestHomeReturnsHelloWorldLocal(t *testing.T) {
                s := server.NewHTTPServer()
                w := httptest.NewRecorder()
                httpReq := httptest.NewRequest(http.MethodGet, "/", nil)
                s.ServeHTTP(w, httpReq)
                require.Equal(t, http.StatusOK, w.Code, "expected status code to match for req %+v", httpReq)
                body := string(w.Body.Bytes())
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
              func TestHomeReturnsHelloWorld(t *testing.T) {
                baseURL, stop := startTestingHTTPServer(t)
                defer stop()
                httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                require.NoError(t, err, "could not create GET / request")
                resp, err := http.DefaultClient.Do(httpReq)
                require.NoError(t, err, "error making request %+v", httpReq)
                require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                body, err := readBodyFrom(resp)
                require.NoError(t, err, "unexpected error reading response body")
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
            
          
internal/server/http_test.go

A LOT of duplication! We can refactor this

            
              func TestHomeReturnsHelloWorldLocal(t *testing.T) {
                s := server.NewHTTPServer()
                baseURL := ""
                makeRequest := func(r *http.Request) (*http.Response, error) {
                  w := httptest.NewRecorder()
                  s.ServeHTTP(w, r)
                  return &http.Response{
                    StatusCode: w.Code,
                    Body:       ioutil.NopCloser(bytes.NewReader(w.Body.Bytes())),
                  }, nil
                }
                httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                require.NoError(t, err, "could not create GET / request")
                resp, err := makeRequest(httpReq)
                require.NoError(t, err, "error making request %+v", httpReq)
                require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                body, err := readBodyFrom(resp)
                require.NoError(t, err, "unexpected error reading response body")
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
              func TestHomeReturnsHelloWorld(t *testing.T) {
                baseURL, stop := startTestingHTTPServer(t)
                defer stop()
                makeRequest := func(r *http.Request) (*http.Response, error) {
                  return http.DefaultClient.Do(r)
                }
                httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                require.NoError(t, err, "could not create GET / request")
                resp, err := makeRequest(httpReq)
                require.NoError(t, err, "error making request %+v", httpReq)
                require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                body, err := readBodyFrom(resp)
                require.NoError(t, err, "unexpected error reading response body")
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
            
          
internal/server/http_test.go

That's a lot closer

            
                type HTTPClient interface {
                  Do(r *http.Request) (*http.Response, error)
                }
                type InMemoryHTTPClient struct {
                  server *server.Server
                }
                func (c *InMemoryHTTPClient) Do(r *http.Request) (*http.Response, error) {
                  w := httptest.NewRecorder()
                  c.server.ServeHTTP(w, r)
                  return &http.Response{
                    StatusCode: w.Code,
                    Body:       ioutil.NopCloser(bytes.NewReader(w.Body.Bytes())),
                  }, nil
                }
                func TestHomeReturnsHelloWorldLocal(t *testing.T) {
                  s := server.NewHTTPServer()
                  httpClient := &InMemoryHTTPClient{server: s}
                  baseURL := ""
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                  require.NoError(t, err, "could not create GET / request")
                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
                  require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "Hello, world", body, "expected body to match")
                }
                func TestHomeReturnsHelloWorld(t *testing.T) {
                  baseURL, stop := startTestingHTTPServer(t)
                  defer stop()
                  httpClient := http.DefaultClient
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                  require.NoError(t, err, "could not create GET / request")
                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
                  require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "Hello, world", body, "expected body to match")
                }
            
          
internal/server/http_test.go

Now it's obvious, the dependencies are different, but the test is the same

Refactor to inject dependencies

              
                func withDependencies(baseT *testing.T, test func(*testing.T, string, HTTPClient)) {
                  testStates := map[string]func(*testing.T) (string, func(), HTTPClient){
                    "unitServerTest":   func(*testing.T) (string, func(), HTTPClient) {
                      s := server.NewHTTPServer()
                      httpClient := &InMemoryHTTPClient{server: s}
                      return "", func() {}, httpClient
                    },
                    "integrationServerTest": func(t *testing.T) (string, func(), HTTPClient) {
                      baseURL, stop := startTestingHTTPServer(t)
                      return baseURL, stop, http.DefaultClient
                    },
                  }
                  for name, dep := range testStates {
                    baseT.Run(name, func(t *testing.T) {
                      baseURL, stop, client := dep(t)
                      defer stop()
                      test(t, baseURL, client)
                    })
                  }
                }
              
            
internal/server/http_test.go

Now we can erase TestHomeReturnsHelloWorldLocal and use TestHomeReturnsHelloWorld

              
                func TestHomeReturnsHelloWorld(t *testing.T) {
                  withDependencies(t, func(t *testing.T, baseURL string, httpClient HTTPClient) {
                    httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                    require.NoError(t, err, "could not create GET / request")
  
                    resp, err := httpClient.Do(httpReq)
                    require.NoError(t, err, "error making request %+v", httpReq)
                  
                    require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                    body, err := readBodyFrom(resp)
                    require.NoError(t, err, "unexpected error reading response body")
                    assert.Equal(t, "Hello, world", body, "expected body to match")
                  })
                }
              
            
internal/server/http_test.go

A little profit

            
              $ $ go test -v ./...
              ?   	github.com/hugocorbucci/multitest-go-example/cmd/server	[no test files]
              === RUN   TestHelloWord
              Responding request
              --- PASS: TestHelloWord (0.00s)
              === RUN   TestHomeReturnsHelloWorld
              === RUN   TestHomeReturnsHelloWorld/unitServerTest
              Responding request
              === RUN   TestHomeReturnsHelloWorld/integrationServerTest
              Responding request
              --- PASS: TestHomeReturnsHelloWorld (0.00s)
                  --- PASS: TestHomeReturnsHelloWorld/unitServerTest (0.00s)
                  --- PASS: TestHomeReturnsHelloWorld/integrationServerTest (0.00s)
              PASS
              ok  	github.com/hugocorbucci/multitest-go-example/internal/server	0.148s
            
          

We can profit more
Add the ability to test an external service

              
                func withDependencies(baseT *testing.T, test func(*testing.T, string, HTTPClient)) {
                  if len(os.Getenv("TARGET_URL")) == 0 {
                    testStates := map[string]func(*testing.T) (string, func(), HTTPClient){
                      "unitServerTest":   func(*testing.T) (string, func(), HTTPClient) {
                        s := server.NewHTTPServer()
                        httpClient := &InMemoryHTTPClient{server: s}
                        return "", func() {}, httpClient
                      },
                      "integrationServerTest": func(t *testing.T) (string, func(), HTTPClient) {
                        baseURL, stop := startTestingHTTPServer(t)
                        return baseURL, stop, http.DefaultClient
                      },
                    }
                    for name, dep := range testStates {
                      baseT.Run(name, func(t *testing.T) {
                        baseURL, stop, client := dep(t)
                        defer stop()
                        test(t, baseURL, client)
                      })
                    }
                  } else {
                    test(baseT, os.Getenv("TARGET_URL"), http.DefaultClient)
                  }
                }
              
            
internal/server/http_test.go

More profit

            
              $ TARGET_URL=http://localhost:8080 go test -v ./...
              ?   	github.com/hugocorbucci/multitest-go-example/cmd/server	[no test files]
              === RUN   TestHelloWord
              Responding request
              --- PASS: TestHelloWord (0.00s)
              === RUN   TestHomeReturnsHelloWorld
              --- PASS: TestHomeReturnsHelloWorld (0.00s)
              PASS
              ok  	github.com/hugocorbucci/multitest-go-example/internal/server	0.034s
              $ TARGET_URL=http://138.197.59.82 go test -v ./...
              ?   	github.com/hugocorbucci/multitest-go-example/cmd/server	[no test files]
              === RUN   TestHelloWord
              Responding request
              --- PASS: TestHelloWord (0.00s)
              === RUN   TestHomeReturnsHelloWorld
              --- PASS: TestHomeReturnsHelloWorld (0.02s)
              PASS
              ok  	github.com/hugocorbucci/multitest-go-example/internal/server	0.065s
            
          

Same code, in memory tests ("unit"), local ("integration") or remote ("smoke")!

            
              func TestHomeReturnsHelloWorld(t *testing.T) {
                withDependencies(t, func(baseURL string, httpClient HTTPClient) {
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/", nil)
                  require.NoError(t, err, "could not create GET / request")

                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)

                  require.Equal(t, http.StatusOK, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "Hello, world", body, "expected body to match")
                })
              }
            
          
internal/server/http_test.go

Pause for questions

Next step:
add a dependency to the service itself

Adding a dependency to the system (and the test)

            
              func main() {
                ll := log.New(os.Stdout, "HTTP - ", 0)
                addr := net.JoinHostPort("", port)

                dbConn := os.Getenv("DB_CONN")
                if len(dbConn) == 0 {
                  dbConn = "root:sekret@tcp(localhost:3306)/multitest"
                }
                db := initializeStore(context.Background(), dbConn, ll)
                repo := storage.NewSQLStore(db)
              
                s := server.NewHTTPServer(repo)
                if err := http.ListenAndServe(addr, s); err != nil {
                  ll.Fatal("HTTP(s) server failed")
                }
              }
            
          
cmd/server/main.go

Fixing the server

            
              type httpHandler struct {
                repo storage.Repository
              }
              // NewHTTPServer creates a new server
              func NewHTTPServer(repo storage.Repository) *Server {
                handler := &httpHandler{repo}
              
                r := mux.NewRouter()
                r.HandleFunc("/", handler.helloWorld).Methods(http.MethodGet)
              
                return &Server{
                  r,
                }
              }
              func (h *httpHandler) helloWorld(w http.ResponseWriter, req *http.Request) {
                msg := "Hello, world"
                fmt.Println("Responding request")
                w.Write([]byte(msg))
              }
            
          
internal/server/http.go

Fixing the unit tests

            
              func TestHelloWord(t *testing.T) {
                handler := &httpHandler{}

                w := httptest.NewRecorder()
                handler.helloWorld(w, nil)

                require.Equal(t, http.StatusOK, w.Code, "expected status code to match")
                body := string(w.Body.Bytes())
                assert.Equal(t, "Hello, world", body, "expected body to match")
              }
            
          
internal/server/http_internal_test.go

Fixing the integration tests

            
              func withDependencies(baseT *testing.T, test func(*testing.T, string, HTTPClient)) {
                if len(os.Getenv("TARGET_URL")) == 0 {
                  testStates := map[string]func(*testing.T) (string, func(), HTTPClient){
                    "unitServerTest": func(t *testing.T) (string, func(), HTTPClient) {
                      repo := stubs.NewStubRepository(nil)
                      s := server.NewHTTPServer(repo)
                      httpClient := &InMemoryHTTPClient{server: s}
                      return "", func() {}, httpClient
                    },
                    "integrationServerTest": func(t *testing.T) (string, func(), HTTPClient) {
                      ctx := context.Background()
                      inMemoryStore, err := sqltesting.NewMemoryStore(ctx, sqltesting.NewTestingLog(t))
                      require.NoError(t, err, "error creating in memory store")
                      baseURL, stop := startTestingHTTPServer(t, inMemoryStore)
                      return baseURL, stop, http.DefaultClient
                    },
                  }
                  for name, dep := range testStates {
                    baseT.Run(name, func(t *testing.T) {
                      baseURL, stop, client := dep(t)
                      defer stop()
                      test(t, baseURL, client)
                    })
                  }
                } else {
                  test(baseT, os.Getenv("TARGET_URL"), http.DefaultClient)
                }
              }
            
          
internal/server/http_test.go

Fixing the integration tests (2)

            
              func startTestingHTTPServer(t *testing.T, inMemoryStore *sqltesting.MemoryStore) (string, func()) {
                ctx := context.Background()
                s := server.NewHTTPServer(inMemoryStore)
              
                listener, err := net.Listen("tcp", "localhost:0")
                if err != nil {
                  t.Fatalf("could not listen for HTTP requests: %+v", err)
                }
                addr := "http://" + listener.Addr().String()
                srvr := http.Server{Addr: addr, Handler: s}
              
                go srvr.Serve(listener)
                return addr, func() {
                  if err := srvr.Shutdown(ctx); err != nil {
                    t.Logf("could not shutdown http server: %+v", err)
                  }
                }
              }
            
          
internal/server/http_test.go

What is this magic MemoryStore?

            
              // NewMemoryStore returns a sqlite "in-memory" repository for local tests.
              func NewMemoryStore(ctx context.Context, l *log.Logger) (*MemoryStore, error) {
                db, err := NewSQLiteDB(ctx, l)
                if err != nil {
                  return nil, err
                }
              
                return &MemoryStore{
                  DB:    db,
                  Store: storage.NewSQLStore(db),
                }, nil
              }
              
              // NewSQLiteDB creates a SQLite DB with the default structure
              func NewSQLiteDB(ctx context.Context, l *log.Logger) (*sql.DB, error) {
                return sql.Open("sqlite3", "file::memory:?mode=memory&cache=shared")
              }
            
          
internal/storage/sqltesting/memory.go

Adding a feature

            
              // NewHTTPServer creates a new server
              func NewHTTPServer(repo storage.Repository) *Server {
                handler := &httpHandler{repo}
              
                r := mux.NewRouter()
                r.HandleFunc("/", handler.helloWorld).Methods(http.MethodGet)
                r.HandleFunc("/s/{short:[a-f0-9]+}", handler.shortURL).Methods(http.MethodGet)
              
                return &Server{
                  r,
                }
              }
            
          
internal/server/http.go

Adding a feature

            
              func (h *httpHandler) shortURL(w http.ResponseWriter, req *http.Request) {
                ctx := req.Context()
                short := mux.Vars(req)["short"]
                if len(short) != 12 {
                  w.WriteHeader(http.StatusNotFound)
                  return
                }
                longURL, err := h.repo.ExpandShortURL(ctx, short)
                if err != nil {
                  if err == sql.ErrNoRows {
                    w.WriteHeader(http.StatusNotFound)
                    w.Write([]byte("404 page not found\n"))
                  } else {
                    w.WriteHeader(http.StatusInternalServerError)
                    w.Write([]byte(fmt.Sprintf("%+v", err)))
                  }
                  return
                }
                w.WriteHeader(http.StatusFound)
                w.Header().Set("Location", longURL)
              }
            
          
internal/server/http.go

Adding a feature

            
              func (h *httpHandler) shortURL(w http.ResponseWriter, req *http.Request) {
                ctx := req.Context()
                short := mux.Vars(req)["short"]
                if len(short) != 12 {
                  w.WriteHeader(http.StatusNotFound)
                  return
                }
                longURL, err := h.repo.ExpandShortURL(ctx, short)
                if err != nil {
                  if err == sql.ErrNoRows {
                    w.WriteHeader(http.StatusNotFound)
                  } else {
                    w.WriteHeader(http.StatusInternalServerError)
                    w.Write([]byte(fmt.Sprintf("%+v", err)))
                  }
                  return
                }
                w.WriteHeader(http.StatusFound)
                w.Header().Set("Location", longURL)
              }
            
          
internal/server/http.go

Testing

            
              func TestShortURLReturnsNotFoundForInvalidURL(baseT *testing.T) {
                withDependencies(baseT, func(t *testing.T, baseURL string, httpClient HTTPClient) {
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/s/invalid", nil)
                  require.NoError(t, err, "could not create GET / request")
              
                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
              
                  require.Equal(t, http.StatusNotFound, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "404 page not found\n", body, "expected body to match")
                })
              }
            
          
internal/server/http_test.go

Testing

            
              func TestShortURLReturnsNotFoundForUnknown(baseT *testing.T) {
                withDependencies(baseT, func(t *testing.T, baseURL string, httpClient HTTPClient) {
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/s/123456789012", nil)
                  require.NoError(t, err, "could not create GET / request")
              
                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
              
                  require.Equal(t, http.StatusNotFound, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "404 page not found\n", body, "expected body to match")
                })
              }
            
          
internal/server/http_test.go

Testing

            
              func TestShortURLReturnsFoundForValidURL(baseT *testing.T) {
                withDependencies(baseT, func(t *testing.T, baseURL string, httpClient HTTPClient) {
                  mocking := false
                  input := "123456789012"
                  output := "https://www.digitalocean.com"
                  if mocking {
                    // TODO: Mockar
                  } else {
                    // TODO: cadastrar a URL
                  }
                  httpReq, err := http.NewRequest(http.MethodGet, baseURL+"/s/"+input, nil)
                  require.NoError(t, err, "could not create GET / request")
                  resp, err := httpClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
                  require.Equal(t, http.StatusFound, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "", body, "expected body to match")
                  assert.Equal(t, output, resp.Header.Get("Location"), "expected location to match")
                })
              }
            
          
internal/server/http_test.go

Uh oh, that's a problem! How to fix it?

The solution:

Dependency Injection

            
              // TestDependencies encapsulates the dependencies needed to run a test
              type TestDependencies struct {
                BaseURL    string
                HTTPClient HTTPClient
              }
              func withDependencies(baseT *testing.T, test func(*testing.T, *TestDependencies)) {
                if len(os.Getenv("TARGET_URL")) == 0 {
                  testStates := map[string]func(*testing.T) (*TestDependencies, func()){
                    "unitServerTest": func(t *testing.T) (*TestDependencies, func()) {
                      repo := stubs.NewStubRepository(nil)
                      s := server.NewHTTPServer(repo)
                      httpClient := &InMemoryHTTPClient{server: s}
                      return &TestDependencies{BaseURL: "", HTTPClient: httpClient}, func() {}
                    },
                    "integrationServerTest": func(t *testing.T) (*TestDependencies, func()) {
                      ctx := context.Background()
                      inMemoryStore, err := sqltesting.NewMemoryStore(ctx, sqltesting.NewTestingLog(t))
                      require.NoError(t, err, "error creating in memory store")
                      baseURL, stop := startTestingHTTPServer(t, inMemoryStore)
                      return &TestDependencies{BaseURL: baseURL, HTTPClient: http.DefaultClient}, stop
                    },
                  }
                  for name, dep := range testStates {
                    baseT.Run(name, func(t *testing.T) {
                      deps, stop := dep(t)
                      defer stop()
                      test(t, deps)
                    })
                  }
                } else {
                  test(baseT, &TestDependencies{BaseURL: os.Getenv("TARGET_URL"), HTTPClient: http.DefaultClient})
                }
              }
            
          
            
              func withDependencies(baseT *testing.T, test func(*testing.T, *TestDependencies)) {
                if len(os.Getenv("TARGET_URL")) == 0 {
                  testStates := map[string]func(*testing.T) (*TestDependencies, func()){
                    "unitServerTest":        unitDependencies,
                    "integrationServerTest": integrationDependencies,
                  }
                  for name, dep := range testStates {
                    baseT.Run(name, func(t *testing.T) {
                      deps, stop := dep(t)
                      defer stop()
                      test(t, deps)
                    })
                  }
                } else {
                  test(baseT, smokeDependencies(baseT))
                }
              }
              func unitDependencies(t *testing.T) (*TestDependencies, func()) {
                repo := stubs.NewStubRepository(nil)
                s := server.NewHTTPServer(repo)
                httpClient := &InMemoryHTTPClient{server: s}
                return &TestDependencies{HTTPClient: httpClient}, func() {}
              }
              func integrationDependencies(t *testing.T) (*TestDependencies, func()) {
                ctx := context.Background()
                inMemoryStore, err := sqltesting.NewMemoryStore(ctx, sqltesting.NewTestingLog(t))
                require.NoError(t, err, "error creating in memory store")
                baseURL, stop := startTestingHTTPServer(t, inMemoryStore)
                return &TestDependencies{BaseURL: baseURL, HTTPClient: http.DefaultClient}, stop
              }
              func smokeDependencies(_ *testing.T) *TestDependencies {
                return &TestDependencies{BaseURL: os.Getenv("TARGET_URL"), HTTPClient: http.DefaultClient}
              }
            
          
internal/server/http_test.go
            
              type TestDependencies struct {
                BaseURL    string
                HTTPClient HTTPClient
                DB         storage.Repository
              }
              func unitDependencies(t *testing.T) (*TestDependencies, func()) {
                repo := stubs.NewStubRepository(nil)
                s := server.NewHTTPServer(repo)
                httpClient := &InMemoryHTTPClient{server: s}
                return &TestDependencies{
                  HTTPClient: httpClient,
                  DB: repo,
                }, func() {}
              }
              func integrationDependencies(t *testing.T) (*TestDependencies, func()) {
                ctx := context.Background()
                inMemoryStore, err := sqltesting.NewMemoryStore(ctx, sqltesting.NewTestingLog(t))
                require.NoError(t, err, "error creating in memory store")
                baseURL, stop := startTestingHTTPServer(t, inMemoryStore)
                return &TestDependencies{
                  BaseURL:    baseURL,
                  HTTPClient: http.DefaultClient,
                  DB: inMemoryStore.Store,
                }, stop
              }
              func smokeDependencies(t *testing.T) *TestDependencies {
                ctx := context.Background()
                l := sqltesting.NewTestingLog(t)
                db := storage.InitializeStore(ctx, "mysql", os.Getenv("DB_CONN"), l, 1, 10*time.Second)
                return &TestDependencies{
                  BaseURL:    os.Getenv("TARGET_URL"),
                  HTTPClient: http.DefaultClient,
                  DB:         storage.NewSQLStore(db),
                }
              }
            
          
internal/server/http_test.go
            
              func TestShortURLReturnsFoundForValidURL(baseT *testing.T) {
                withDependencies(baseT, func(t *testing.T, deps *TestDependencies) {
                  repo, mocking := deps.DB.(*stubs.Repository)
                  input := "123456789012"
                  output := "https://www.digitalocean.com"
                  if mocking {
                    repo.Add(input, output)
                  } else {
                    // TODO: cadastrar a URL
                  }
                  httpReq, err := http.NewRequest(http.MethodGet, deps.BaseURL+"/s/"+input, nil)
                  require.NoError(t, err, "could not create GET / request")
                  resp, err := deps.HTTPClient.Do(httpReq)
                  require.NoError(t, err, "error making request %+v", httpReq)
                  require.Equal(t, http.StatusFound, resp.StatusCode, "expected status code to match for req %+v", httpReq)
                  body, err := readBodyFrom(resp)
                  require.NoError(t, err, "unexpected error reading response body")
                  assert.Equal(t, "", body, "expected body to match")
                  assert.Equal(t, output, resp.Header.Get("Location"), "expected location to match")
                })
              }
            
          
internal/server/http_test.go

Got it right?

In the end, your test is also software

Use the techniques you use
in your production software

for your test software

But, Hugo, that's going to be super slow!

            
              $ for f in $(find . -name '*_test.go'); do grep 'func Test' $f; done | wc -l
              179
              $ for f in $(find . -name '*_test.go'); do cat $f; done | wc -l
              4893
              $ for f in $(find . -name '*.go' | grep -v '_test.go'); do cat $f; done | wc -l
              4811                              64           1528            190           7986
              $ time make test
              go test -tags=integration ./...
              ...
              74.55 real       145.33 user        27.76 sys
              $ PUBLIC_SMOKE_TESTS=true ENV=production make smoke_tests
              ../smoke_tests/bin/run
              ...
              [go] Task status: passed, took: 1m 46.536s
            
          

But, Hugo, what about flaky tests?

Pipeline activity showing 7 deployments in a row, all passing no failures

Other improvements we've had

Use that test suite to verify generated DB query performance

Run this suite synthetically outside of the system to find external problems

Our architecture is testable by our users (since we test it like they would)

Next steps

Use the mock/fake setups in tests to validate compability with the actual results in real

Improve the setup/preparation of data for test runs

Try in other contexts

That's it

Thank you!

hcorbucci@digitalocean.com

twitter.com/hugocorbucci