TL;DR — Test the Behaviour not the Implementation
The key take away is when we work with TDD:
- Write failing tests
- Write tests against the observable behaviours, which in go is the package’s public APIs
- Do not write tests for the implementation which would start failing if we refactored the implementation
- Write the implementation as quickly as possible, it can be a C&P from StackOverflow! Just get the test to pass asap!
- REFACTOR! Deduplicate code; use design patterns; remove code smells; make it maintainable; don’t write any new tests.
The final step is important and can only be achieved if we only test the behaviour of the public APIs and not the implementation details.
Use mocking sparingly. They are best used for fixtures such as communicating with databases and other services.
What I used to do
I was first introduced to testing and TDD through Clean Code by Robert C. Martin (aka Uncle Bob). It was great! I was more confident with my code, required less manual testing and was confident with the tests themselves too. This is software engineering as it should be, and I was confident and satisfied with my new found skill, so I proudly plastered it all over my CV.
What I didn’t realise, until recently, was that I had fallen into the same trap as many other programmers — over testing; tests strongly coupled to the implementation (brittle tests); more test code than actual implementation code; and the tests hindering refactoring.
What I’ve Learned
I was recently involved in a colleague’s PR, where several discussions ensued over when and how to write unit tests. I soon realised that what I thought was the correct way to test wasn’t inline with my colleagues’ way.
I had to step back to try and understand what they were talking about, and I eventually did come around to their way of thinking, although I no longer felt confident in knowing when and how to write tests.
I still needed something more formal to help me grasp the idea, so I did what any new Go developer would do these days, check Dave Cheney’s blog to see if he’d written about it, and thankfully he had.
The following link points to all the blog entries Dave has tagged under testing, well worth a read:
I’m a big fan of testing, specifically unit testing and TDD ( done correctly, of course). A practice that has grown…
In the following video Dave talks about how to test in Go. It’s a great video, but it didn’t clarify some points, especially around mocks.
The following video is the one that Dave based his testing idea on. It’s a must for anyone who wants to understand how best to work with TDD.
To help me remember my new revised understanding of testing with TDD, i’ve written down the key points that i think are important from the three sources above.
First of all, it’s worth introducing our challenger — the duct-tape programmer, who writes quick and “dirty” code to get the work done, which makes business happy, but not other engineers who have to maintain the code. The downside of their approach is that the code is usually not maintainable, difficult to understand and impossible to refactor without first writing tests. We can’t keep up with this programmer if we write tests for everything we implement; this way of testing happens to hinder refactoring too and therefore increases tech debt. If we correctly utilise TDD we can keep up with this programmer, while also keeping our code maintainable, properly tested, and all while keeping business happy.
We should follow the red green refactor method of writing tests in TDD — philosophy behind this is that we can only do one thing at a time, either code the specified behaviour or structure the code well, but not both at the same time.
Here are the steps in more details:
Write a test that fails
Write the minimum amount of code for the test to fail. It’s up to you whether that means it doesn’t compile or it does but the test actually fails.
Unit tests should cover all observable behaviours of the system. Any observable behaviours that are not covered by tests will still be dependent upon, so you’re encouraged to use a coverage tool to find them all — relates to Hyrum’s Law.
Observable behaviours in Go are the package level public API. So our tests are the first users of our API which is great as it will help us to verify whether the API makes sense.
If you’re really unsure on a particular implementation detail it’s fine to write a test to help flesh it out, but remove it once you’re happy with the implementation.
By not writing/leaving tests that test the implementation we can safely refactor the implementation without affecting the behaviour of the API. This is a very important point as tests that impede refactoring also impede the reduction in tech debt.
When possible use table driven tests, which are easier to maintain, reduce code duplication, and help ensure all observable behaviours are accounted for.
Write the minimum amount of code to get it to pass
You need to be the duct-tape programmer here, write it like they would. Speed is key here, so if you want just copy and paste it from stackoverflow, or another service, or a scrap book of useful code. We want the test to pass asap so that we can move onto the next very important step.
Yes, this is very important. This is where I was going wrong all this time. I forgot about the refactor step. If I hadn’t then maybe I wouldn’t be writing this blog post.
By refactoring our code we are ensuring that we’re:
- testing the behaviour
- not testing the implementation detail
- not leaving tech debt from the get go
- not adding additional observable behaviour — run the coverage tool to see whether there’s any refactored code that isn’t being covered. If there are new behaviours, try to remove them or add new tests to cover them
- allowing other developers to easily refactor the code without affecting the observable behaviours.
We shouldn’t be writing any other tests at this stage.
When you’re testing the observable behaviours of your package, don’t mock internal dependencies. Mocking heavy fixtures (such as calls to interact with databases, gRPC and HTTP services) are fine to do. We can do this by creating a thin layer between our code and the implementation details of the fixture.
If you look at a digram of hexagonal architecture, the unit tests that we’re creating are testing the core of the application. We can test the interaction between the core and the adapter layer using mocks.
We can use integration tests to ensure we’ve hooked up the core and the adapter layer correctly, and end-to-end tests can be used to test the full flow from request to response involving the direct and indirect external dependencies.
I now understand why we wrote the test as we did for the PR in question. What was the PR? It was basically an ETL, collating data from other services and sending the data off elsewhere to be saved.
So now we have no implementation tests; several behaviour tests to ensure we’re getting the expected output after the specified input; and using mocks for the heavy fixtures (other services, messaging service and a database).
In the future we can easily refactor the code without touching the tests. If the fixtures change, the behaviour changes, so the test will need to change to reflect the behaviour change.
I’ve created a very simple service that shortens a URI, where I followed the steps outlined above — https://github.com/ankur22/unit-tests-example.
In the example, I created the tests and implementation, before creating two ways of interacting with the business logic — via cli and http requests (the adapters). I was then able to use integration tests to ensure that the adapter and core were hooked up correctly.
go test ./… -v Run all the tests
go test ./… -coverprofile cp.out Coverage
go tool cover -html=cp.out Visualise coverage as html
go test -run=XXX -bench=. -benchtime=10s Run benchmark tests
go test -run=TestURLShortner/Race Only run the Race test
TDD by example by Kent Beck
Refactoring by Martin Fowler
Refactoring Patterns by Joshua Kerievsky