This was originally a talk given by Quinn Slack at Pivotal Labs in their weekly series. Many thanks to Pivotal for inviting us! Check out the slides here (source).
Nearly every programmer agrees that testing is important. But testing can be difficult in a variety of ways that discourage people from writing them. They can be slow to run, they can involve a lot of repetitive boilerplate, or they might test too much at once, making it difficult to identify the root cause of a test failure.
In this post, we'll talk about how we've designed the unit tests for Sourcegraph to be easy to write, easy to maintain, quick to run, and usable by others. We hope that some of the patterns we mention here will be useful to others writing Go web apps and also welcome suggestions for improving the way we test. Before get into testing, though, here's a quick overview of our architecture.
Like many web apps, our site has three layers:
When a user requests a page on Sourcegraph, the frontend receives the HTTP request for the page and issues a set of HTTP requests to the API server. The API server then queries the data store. The data store returns the data to the API server, which then marshals it as JSON that's returned to the frontend web server. The frontend then uses the Go html/template package to display and format this data as HTML.
Here's what it looks like:
(For more details about our architecture, check out the
recap of our Google I/O talk about building a large-scale code search engine in Go.)
When we first started building Sourcegraph, we wrote our tests in a manner that was easiest to get up and running. Each test would populate the database with fixtures and then issues HTTP GET requests to the API endpoints being tested. The test would parse the HTTP response body and check this against expected data. A typical v0 test looked something like this:
func TestListRepositories(t *testing.T) { tests := []struct { url string; insert []interface{}; want []*Repo }{ {"/repos", []*Repo{{Name: "foo"}}, []*Repo{{Name: "foo"}}}, {"/repos?lang=Go", []*Repo{{Lang: "Python"}}, nil}, {"/repos?lang=Go", []*Repo{{Lang: "Go"}}, []*Repo{{Lang: "Go"}}}, } db.Connect() s := http.NewServeMux() s.Handle("/", router) for _, test := range tests { func() { req, _ := http.NewRequest("GET", test.url, nil) tx, _ := db.DB.DbMap.Begin() defer tx.Rollback() tx.Insert(test.data...) rw := httptest.NewRecorder() rw.Body = new(bytes.Buffer) s.ServeHTTP(rw, req) var got []*Repo json.NewDecoder(rw.Body).Decode(&got) if !reflect.DeepEqual(got, want) { t.Errorf("%s: got %v, want %v", test.url, got, test.want) } }() } }
Writing tests this way was easy and simple at first, but became the source of a lot of pain as our app evolved. Over time, we added more features. More features led to more tests, and the tests took longer to run, slowing down our dev cycle. More features also required changing and adding new URL routes (there are now about 75 of them), many of which are fairly complex. Each layer of Sourcegraph also became more complex internally, so we wanted to test them in isolation from the other layers.
Here are some of the testing-related problems we ran into:
Finally, another need that popped up was that we wanted to make an API client that we could release publicly. And we wanted that API to be easily mockable so that consumers of our API could write well-tested code, too.
As our app evolved, we realized we needed tests that met these high level requirements:
Writing good, maintainable tests goes hand-in-hand with writing good, maintainable application code. Re-structuring our application code enabled us to vastly improve our test code. Here are the steps we took to improve our tests.
The first step to simplifying our tests was writing a high-quality client for our API in Go. Previously, our site had been an AngularJS app, but because we were primarily serving static content, we decided to move the frontend HTML generation to the server. After doing this, it made sense to write an API client in Go that our new frontend could use to communicate with the API server. The client, go-sourcegraph, is open-source and its design was heavily influenced by the awesome go-github library. The code for the client (specifically, the endpoint for fetching repository data) looks something like this:
func NewClient() *Client { c := &Client{BaseURL:DefaultBaseURL} c.Repositories = &repoService{c} return c } type repoService struct{ c *Client } func (c *repoService) Get(name string) (*Repo, error) { resp, err := http.Get(fmt.Sprintf("%s/api/repos/%s", c.BaseURL, name)) if err != nil { return nil, err } defer resp.Body.Close() var repo Repo return &repo, json.NewDecoder(resp.Body).Decode(&repo) }
Previously, our v0 API tests had hardcoded a lot of URL routes and constructed HTTP requests in an ad-hoc manner. Now, they could use this API client to construct and issue requests.
Next, we unified the interfaces of our HTTP API client and datastore. Previously our API http.Handlers were issuing SQL queries directly. Now our API http.Handlers just needed to parse the http.Request and then just call our datastore, which implemented the same interface as the HTTP API client.
Mirroring the (*repoService).Get method from the HTTP API client above, we now also had (*repoStore).Get:
func NewDatastore(dbh modl.SqlExecutor) *Datastore { s := &Datastore{dbh: dbh} s.Repositories = &repoStore{s} return s } type repoStore struct{ *Datastore } func (s *repoStore) Get(name string) (*Repo, error) { var repo *Repo return repo, s.db.Select(&repo, "SELECT * FROM repo WHERE name=$1", name) }
Unifying these interfaces put the description of the behavior of our web app in one place, making it easier to understand and reason about. Moreover, we could reuse the same data types and parameter structs in both the API client and datastore.
Previously, we had to redefine URL routes in multiple layers of our application. In our API client, we had code like
resp, err := http.Get(fmt.Sprintf("%s/api/repos/%s", c.BaseURL, name))
This was very error prone, because we had over 75 route definitions, many of which were complex. Centralizing the URL route definitions meant refactoring the routes into a new package, separate from the API server. The definitions of routes were declared in the route package:
const RepoGetRoute = "repo" func NewAPIRouter() *mux.Router { m := mux.NewRouter() // define the routes m.Path("/api/repos/{Name:.*}").Name(RepoGetRoute) return m }while the http.Handlers were actually mounted in the API server package:
func init() { m := NewAPIRouter() // mount handlers m.Get(RepoGetRoute).HandlerFunc(handleRepoGet) http.Handle("/api/", m) }Now, in the API client, we could use the route package to generate URLs, instead of having to hardcode them. The (*repoService).Get method is now:
var apiRouter = NewAPIRouter() func (s *repoService) Get(name string) (*Repo, error) { url, _ := apiRouter.Get(RepoGetRoute).URL("name", name) resp, err := http.Get(s.baseURL + url.String()) if err != nil { return nil, err } defer resp.Body.Close() var repo []Repo return repo, json.NewDecoder(resp.Body).Decode(&repo) }
Our v0 tests tested the router, HTTP handlers, SQL generation, and DB querying at the same time. Test failures were hard to diagnose, and the tests were very slow.
Now, we have separate tests for each layer and we mock the functionality of the adjacent layers. Because each layer of our application implements the same interface, we can use the same mock implementation of that interface in all 3 layers!
The implementation of the mock is simply a struct of stub functions, which can be specified in each test:
type MockRepoService struct { Get_ func(name string) (*Repo, error) } var _ RepoInterface = MockRepoService{} func (s MockRepoService) Get(name string) (*Repo, error) { if s.Get_ == nil { return nil, nil } return s.Get_(name) } func NewMockClient() *Client { return &Client{&MockRepoService{}} }And here it is in use in a test. We mock the datastore's RepoService and use our HTTP API client to test the API http.Handler. (This snippet uses all the stuff we talked about above!)
func TestRepoGet(t *testing.T) { setup() defer teardown() var fetchedRepo bool mockDatastore.Repo.(*MockRepoService).Get_ = func(name string) (*Repo, error) { if name != "foo" { t.Errorf("want Get %q, got %q", "foo", repo.URI) } fetchedRepo = true return &Repo{name}, nil } repo, err := mockAPIClient.Repositories.Get("foo") if err != nil { t.Fatal(err) } if !fetchedRepo { t.Errorf("!fetchedRepo") } }
Using the patterns mentioned above, we've achieved our goals for testing. Our test code is:
So there you have it—the story of how we rebuilt and improved Sourcegraph's tests. The patterns and examples we've covered have worked well for us, and we hope they'll be useful to others in the Go community. But obviously they may not be right for every scenario, and we're sure there's room for improvement. We're constantly trying to improve the way we do things, so we'd love to hear your suggestions and feedback—tell us about your own experiences writing tests in Go!
Follow @srcgraph on Twitter, and email us at hi@sourcegraph.com. And try Sourcegraph now!
Also, we're hiring! Help us make programming faster, more accessible, and more enjoyable while building a strong open-source community around creating semantically aware tools for programmers. Email us and introduce yourself.