Unit Test Howto

There are way too many reasons to write unit tests, but my favorite one is: the freedom to hack, modify and improve the code without fear, and get quick feedback that you are on the right track.

Of course, writing good integration tests (the stuff under the tests directory) is the best way to validate that everything works, but unit tests has great value as:

  • They are much faster to run (~30 seconds in our case)
  • You get nice coverage reports with coveralls
  • No need to: make cluster up/sync
  • Cover corner cases and easier to debug

Some Notes:

  • We use same frameworks (ginkgo, gomega) for unit testing and integration testing, which means that with the same learning curve, you can develop much more!
  • “Bang for the Buck” - it usually takes 20% of the time to get to 80% coverage, and 80% of the time to get to 100%. Which mean that you have to use common sense when improving coverage - some code is just fine with 80% coverage (e.g. large files calling some other APIs with little logic), and other would benefit from getting close to 100% (e.g. complex core functionality handling lots of error cases)
  • Follow the “boy (or girl) scout rule” - every time you enhance/fix some code, add more testing around the existing code as well
  • Avoid “white box testing”, as this will cause endless maintenance of the test code. Best way to assure that, is to put the test code under a different package than the code under test
  • Explore coveralls. Not only it will show you the coverage and the overall trend, it will also help you understand which tests are missing. When drilling down into a file, you can see hits per line, and make better decision on what needs to be covered next

Frameworks

There are several frameworks we use to write unit tests:

  • The tests themselves are written using ginkgo, which is a Behavior-Driven Development (BDD) framework
  • The library used for assertions in the tests is gomega. It has a very rich set of matchers, so, before you write you own code around the “equal” matcher, check here to see if there is a more expressive assertion you can use
  • We use GoMock to generate mocks for the different kubevirt interfaces and objects. The command make generate will (among other things) create a file holding the mocked version of our objects and interfaces
    • Many examples exist in our code on how to use this framework
    • Also see here for sample code from GoMock
  • If you need mocks for k8s objects and interfaces, use their framework. They have a tool called client-gen, which generates both the code and the mocks based on the defined APIs
    • The generated mock interfaces and objects of the k8s client are here. Note that they a use a different mechanism to control the mocked behavior than the one used in GoMock
    • Mocked actions are more are here
  • Unit test utilities are placed under testutils
  • Some integration test utilities are also useful for unit testing, see this file
  • When testing interfaces, a mock HTTP server is usually needed. For that we use the golang httptest package
    • gomega also has a package called ghttp that could be used for same purpose

Best Practices and Tips

ginkgo

  • Don’t mix setup and tests, use BeforeEach/JustBeforeEach for setup and It/Specify for tests
  • Don’t write setup/cleanup code under Describe/Context clause, which is not inside BeforeEach/AfterEach etc.
  • Make sure that any state change inside an “It” clause, that may impact other tests, is reverted in “AfterEach”
  • Don’t assume the “It” clauses, which are at the same level, are invoked in any specific order

    gomega

    Be verbose and use specific matchers. For example, to check that an array has N elements, you can use:

    Expect(len(arr)).To(Equal(N))
    

    But a better way would be:

    Expect(arr).To(HaveLen(N))
    

    Function Override

    Sometimes the code under test is invoking a function which is not mocked. In most cases, this is an indication that the code needs to be refactored, so this function, or its return values, will be passed as part of the API of the code being tested. However, if this refactoring is not possible (or too costly), you can inject your own implementation of this function. The original function should be defined as a closure, and assigned to a global variable. Since functions are 1st class citizens in go, you can assign your implementation to that function variable. More detailed example is here