How to Write Better Tests in Go
How do you know if what you are building is the right thing? How do you know if the product you just put into customers’ hands doesn’t harm them? Don’t put them into hospital or worse, kill them?
Do you remember the last time you fixed something at home? Maybe you hung a shelf or a picture on the wall. Or you fixed electric cables or connected a washing machine to water pipes. Did you drill and cut walls, pipes and cables without planning and measuring first? It sounds ridiculous.
Could you recommend a good electrician? Oh, not a problem! All alive must be good!
Industry joke
As ridiculous as it sounds, it’s not impossible. Worse, it appears that is not so uncommon. How about the software industry?
We don’t need to look far away for examples. In the How to Break Production on Black Friday, we learn how building products and incorrectly using the measuring tools ends up with a disaster.
In How to Prevent Panics in Go, we analyse mistakes that crash the Ingress Controller, putting thousands of production Kubernetes clusters at risk. We learn that the advice to expect the unexpected is more than an empty phrase.
The right mindset
So, how do we build software products using the right measuring tools? Can we apply similar engineering practices that electricians, carpenters, plumbers, and civil engineers do? In the end, the work and thinking processes are similar. We all build. We all create for ourselves and for other humans. What differs are the tools of the trade.
First, we must have a plan—some kind of specification, requirements, and an idea of what to build for whom. Applications for insulin pumps, pacemakers differ from mobile apps tracking daily habits. Different risk levels bring different engineering challenges.
Measure twice, cut once.
Regardless of what software we build, we must frequently check the progress. We must know if the steps we take are correct. That’s why we use tests, the equivalent of a measuring tape, to help design applications.
In the same way, builders put each brick on the wall and check if it’s straight. We design and run tests more or less the same way. Each time before we build and ship, we design a test. We create a mental model of how a tiny part of the application should work. Next, we develop and measure. When we finish, we run a test and verify that everything goes according to the plan—one small step at a time. Sketch a plan, build, and compare it with the sketch. Rinse and repeat.
The 4 question framework
Before we start working on the Go validation package, we will review the Four Question Framework. It will help us create a mental model, design tests, focus on security, and build the Go package.
When we use the framework, we must answer the questions:
- What are we working on?
- What can go wrong?
- What are we going to do about it?
- Did we do a good job?
What are we working on?
There is no point in writing, developing, and changing code if we don’t know what to achieve. We must understand what we are building, for whom, and what business problems we are solving.
Imagine you want to build a house. You don’t start the project by digging a big hole in the middle of your yard. You don’t buy a ton or two of bricks and leave planning to the end.
You must have a plan and know how the house will look, including its size, functionality, capacity, etc. In the beginning, you don’t care about details like wall colours or places for pictures and flowers.
What can go wrong?
Now it’s time to scrutinise the plan. We stop and think about what can go wrong. Are there any undocumented pipes or electric cables running under the plot? What’s the weather? Is the ground muddy? Is it windy? Do we expect heavy rains?
Just like questions a civil engineer would ask, you must question every decision regarding your software product. You anticipate data size, processing pipelines, and possible mistakes. How does the application error? Does the app crash? Does it corrupt the user’s data?
The longer you think and analyse how the data flows through the system, the more questions and scenarios you will ask.
What are we going to do about it?
How would you secure your building site and your yard? Would you use health and safety equipment? Some industries dictate what you need to do, how to secure your workplace, and how to follow safety procedures.
Besides this, you consult what you do with the plan, manuals and procedures. You don’t rush. You don’t cut corners. You don’t skip steps to finish earlier.
You must have a similar mindset when building software. You use your brain to think and anticipate problems.
When you suspect what could go wrong, you plan how to prevent errors and disasters. That’s where your imagination shines. This step is crucial as a lack of error handling causes most bugs and security holes.
Are you sure that “this error won’t happen” or “we don’t need error handling here”?
What if it happens some day or night? Your job is to minimise users’ WTF moments and eliminate the Pager Duty alarms.
Did we do a good job?
How can you answer this question? What data and criteria do you use?
On the construction site, you measure the thickness of walls, insulation, water pipes, electric cables, connections, and many other things. You compare and consult technical documentation multiple times a day. Step after step.
You don’t build the entire house at once and compare the finished building with the specifications. It could be too late. You build it step by step. You assemble pipes and test it immediately. You have tools for it. You lay a brick and inspect it. You take a step, test it, and correct it if necessary.
So, what about the software? How do you make sure you did a good job? One step at a time.
It’s simple. You write a test. You use a small test as your micro-level specification. The test is not passing as there is nothing to test yet. Next, you build a small part of a program and check it. You run the test. Is it green? Fine. Is it red? Correct what you make.
You are not allowed to build walls on top of incorrectly built foundations, and you are not allowed to connect electric sockets to wrong or damaged electric cables. Building software requires the same engineering mentality.
The cycle continues. You test every step and consult the specification—requirement documents. Some call it the red-green-refactor cycle. Some call it TDD. Some call it test-first. It really doesn’t matter. What matters is using the right tools to answer the question, “Did we do a good job?”.
Building Policy validator
Before building the validator
, we need to clarify requirements. In the meantime, you can start preparing a git repository and placeholder files in the Go package.
Our package consist of three files.
├── go.mod
├── validator.go
└── validator_test.go
We know already how the Policy object looks like in the YAML format
apiVersion: k8s.nginx.org/v1
kind: Policy
metadata:
name: api-key-policy
spec:
apiKey:
suppliedIn:
header:
- "X-header-name"
query:
- "queryName"
clientSecret: api-key-client-secret
and how in the JSON
{
"apiKey": {
"clientSecret": "api-key-client-secret",
"suppliedIn": {
"header": [
"X-API-Key"
],
"query": [
"apiKey"
],
}
}
}
Do we know what business rules our validator
should include? A few questions can help:
- What data should the Policy object model?
- What are data types in fields in the object?
- Which fields are mandatory, and which ones aren’t?
- Is there any correlation between the fields?
- Are some fields mutually exclusive?
- What are the allowed values for each field?
Once we have answers, we will be ready to start building a safety net—our tests. We will do this step by step, test by test.
Modeling data
We use two structs to model the API Key object. The main one, APIKey
has two exported fields: SuppliedIn
and ClientSecret
.
type APIKey struct {
SuppliedIn *SuppliedIn
ClientSecret string
}
The first field, SuppliedIn,
is a pointer to the SuppliedIn
struct. The second field, ClientSecret,
is a string
type.
The SuppliedIn
struct has two fields, Header
and Query
. Both are slices of strings.
type SuppliedIn struct {
Header []string
Query []string
}
The SuppliedIn
represents where we put the API key—in the request header (Header
field) and the request query (Query
field).
That’s it. We have a structure representing some data. We also know the rules for all the fields. How do we validate the constraints?
Let’s open an editor and the validator_test.go
file. It’s time to design our first test.
But this will be our first task for our next coding session. We will turn the blank open file into a first-class design specification, step by step, test by test.