Testing Your S3 Abstraction for Fun and Profit in Go

by Craig Smith in


 

Go is a statically typed compiled language. This warms my not-so-inner compiler nerd heart. There are so many wonderful things about compiled languages (sorry Ruby) but this is a severe handicap for BDD testing (sorry Go) as we can’t stub out methods with specific input and output parameters.

Take for instance RSpec’s double function for creating empty mock objects. Personally, I confuse this with the type double so I would have preferred the name doppel as in doppelganger. Be that as it may, using this function you can quickly create a mock object and then use the stub method to attach a method that returns specific values for the specified parameters. This doesn’t exist in Ginkgo, our BDD testing package of choice, but Google's GoMock package was built to fill that need.

To ground Ginkgo and GoMock with concrete examples, we'll take the abstraction built in the previous blog entry (“Abstracting S3 for Fun and Profit”) and complete the journey to shippable code by writing unit tests.

Ginkgo

If you’re familiar with RSpec in Ruby you’ll be very comfortable with Ginkgo. To introduce writing tests using Ginkgo we’ll write unit tests for the Person struct introduced in the previous blog post. Note the deliberate omission of tests for S3Fetcher; we’re not focusing on those tests in this section. That will be covered when we show Ginkgo’s limits and how GoMock facilitates tests beyond those limits.

Ginkgo project setup will not be covered here. Refer to the Ginkgo documentation for setup details such as bootstrapping a testing suite and adding an empty spec file. Instead, this post will focus on the tests themselves. We’ll test the behavior of the Person, that means testing the public functions. We’ll start with ExtractPersonData since it doesn’t involve S3. We’ll provide a descriptive Context for testing that function and then enumerate specific tests with the It function.  Remember the keywords in Ginkgo are not magical language extensions, but simply functions. Keeping this in mind helps one write better testing code.

 

 

This post won’t go into the details of each and every Ginkgo method; for more on that feel free to refer to the excellent documentation. The key methods to notice are Expect and To and ToNot--these are fundamental to testing with Ginkgo. The code above is straightforward and easy to read by design.

Testing ReadPersonFromS3 is more difficult. This is the philosophical moment in the post. How should we specify the data we want downloaded from S3? Clearly we don’t want to put data in S3 for testing.  Better, but still less than perfect, is to create a local file, committed to VCS containing test data and use the FileFetcher to retrieve it (FileFetcher is defined in the first post of this series). We could use the StringFetcher object, left as an exercise for the reader in the previous blog post. That option would look something like this:

The data is embedded in the test, rather than in separate resource, which improves readability. Granted, the use of BeforeEach and JustBeforeEach obfuscates the code somewhat, but it is useful to separate object configuration from construction. As an aside, JustBeforeEach is a horrific naming choice. It is unclear from the name if the block is executed before the BeforeEach blocks or between the each BeforeEach and the affected It blocks. To save you time reading (and rereading) documentation, it is the latter.

It might appear odd to have a Context embedded inside another Context. There’s good reason for this and the code diff below highlights the problem. If we move the BeforeEach on line 72, on the left side, outside the surrounding Context to line 71 on the right side of the diff, our first test will fail. One might assume that BeforeEach and It blocks are executed in order, but that is not the case. BeforeEach registers the anonymous function for the current scope, so it is called before each element in the same scope. The It test on line 68 is a peer to the BeforeEach block and therefore the assignment of empty string to the jsonData variable is executed first and used to create the StringFetcher on line 65. This causes the expect on line 69 to fail because  ReadPersonFromS3 returns an error (parsing empty JSON string) instead of an array of two Person objects.

The takeaway is always place your BeforeEach blocks at the beginning of the scope you want to affect.

So how do we get from here to using GoMock? There are a few issues with this approach that GoMock addresses. Using the approach above, we can’t assert internal properties of the S3Fetcher object. For instance, we want to set expectations around GetObject and we have no insight at that level of detail. We could expose functions on the Fetcher object to accomplish this, but that requires mixing test code with production code. In other words, outside of testing that functionality, that feature would not be used, therefore it is out of scope. It is better to keep functionality specific to testing inside the test files. That being the case we should generate those objects so we don’t have to write and maintain that code. GoMock, and its generated mock objects, fills this role.

GoMock

The GoMock package generates mock objects from interfaces. When a file and the name of the interface to mock are passed to the mockgen tool (built when you install GoMock) it generates a mock object. The mock object it generates at first glance appears magical; learning how that code works will go a long way to being a better Go programmer. The mock object has all the methods from the provided interface but also hooks that allow us to inject desired behavior.

In the previous post we created an interface, S3Fetcher, that stood between our code and a single call to AWS to retrieve the S3 object that contained JSON data for a Person array. We’ll take advantage of that separation of concerns and use GoMock to create a mock object that plays the role of the S3Fetcher.

To create the mock object install GoMock and use the mockgen tool to generate the mock S3Fetcher object:

% mockgen -source s3fetcher/s3fetcher.go S3Fetcher > mocks/mock_s3fetcher/mock_s3fetcher.go

The seemingly extraneous directory “mock_s3fetcher” is necessary because the contents of mock_s3fetcher.go will be generated as a package of the same name. The directory allows the import, at the top of the person_test.go file, to work correctly:

 

At the beginning of the test file we need to create a new gomock controller and clean it up afterwards:

 

These blocks will execute before and after each test (It block) in the Describe block.

With the setup complete, we can now write our test using the mock object. Using the MockS3Fetcher will elevate this test to legendary status by allowing the test to:

  • Specify exactly what bucket and key to retrieve the JSON Person data from.
  • Specify the JSON data that will be returned—no more StringFetcher.
  • Specify how many times GetObject is invoked and generate a failure if that’s not the case

The easiest way to understand how this test works is to breakdown the right hand side of the  assignment to testCall (line 17) in the BeforeEach:

mockFetcher.EXPECT().GetObject(objectInput)

This configures the mock object to allow a call to GetObject targeting the bucket “testbucket” and key “persondata.txt”. The next portion of the expression:

.Return(goodObjectOutput, nil)

configures what is returned when you call GetObject on this mock object for that specific input. In this case it returns an s3.ObjectOutput object containing a MockBody object wrapping the JSON we want to return. The final part of the expression:

.AnyTimes()

specifies the number of times this method will be called. In this case there’s no restriction on the number of times called. However, in It body we assert that the method is called only once:

testCall.Times(1)

We could remove this line and replace the “Anytimes()” (in the BeforeEach) with “Times(1)” for the same effect. However, I’m expecting to write more tests so I don’t want the call constraint the same for all tests affected by the BeforeEach.  Also, for readability it just makes sense to have all your constraints together in the same It block.

As a sidebar remember where we added the AfterEach call to clean up the gomock.Controller? Well that’s where it checks to see if all Times invocation count constraints are satisfied for this test. Remember that the BeforeEach and AfterEach anonymous functions are per tests for all the tests within its scope and since these are at the top of the test suite that code is run for every test.

That’s the heavy lifting, in terms of bringing Ginkgo and GoMock together, but we’re not done examining the testing opportunities. We can, and need to, test when GetObject on the S3Fetcher returns an error. Note this is different from the erroneous JSON test, the second test using the StringFetcher. That test didn’t return an error from GetObject--we’re testing the error handling in different places.

 

We could write this test without using a mock object, but then we’d have to write a custom Fetcher with the GetObject method returning an error. That’d be a waste of code, especially when GoMock exists and gives us so much power and flexibility. Take note of gomock.Any() --  a constructor that returns a parameter matcher object for, as the name implies, matching any input value. For this test we don’t care what the input is, since we’re returning an error.

Conclusion

We haven’t written all the tests for Person, but that’s not necessary to introduce Ginkgo and GoMock. Using the generated mock, and injecting it into the interfaces separating responsibilities in your code, we can more thoroughly test our code without external dependencies. GoMock has a steep learning curve, but it richly rewards the effort. Give it a bit of time and soon you’ll identify places in your production code to introduce interfaces you can mock in your tests.

Thanks to Dean Dieker, Sean “Stabby” Kelly, Christian Carrasco and Chris Weekly for their invaluable help with production code that lead to topic or their suggestions that improved this post.