Critical First Step in Go Tests
“I have two news for you, bad and worse,” said the mechanic. “The battery is dead and I won’t get a new one until Monday. Forget about your annual inspection today,” he continued. “But…” you started. “There is no but.” he cut short.
No setup, no testing. Just like in your Go program.
What are the prerequisites for testing lights and CO2 emissions in your car? The battery needs to be there and be in good condition. Without the battery, the engine won’t start. Inspecting the car will be impossible. You follow the same logic when writing tests in Go
When you work on new functionality, you need feedback to see if your implementation works as intended.
You run the function and check if the result you get is what you want. Next, you feed the function with invalid data and learn how resilient it is.
But before running the tests, you write a piece of code that generates preconditions for tests, such as setup variables, servers, and clients. Without getting this step right, tests are worthless.
We divide test inputs into two categories:
- correct - to run the function, compute and return value(s)
- invalid - to prove that our function doesn’t crash and doesn’t return a non-nil error value
The same principle applies to creating preconditions. We must set up the system (variables, servers, clients, etc.) for our code to run.
For example, we need a running server to test uploading, downloading files, or sending messages between clients and a server.
Let’s look at the fragment of a test (taken from the OSS Go package implementing the Modbus protocol) divided into three sections:

func TestTCPoverTLSClient(t *testing.T) {
// Section 0 - variables
var err error
var serverKeyPair tls.Certificate
var clientKeyPair tls.Certificate
// Section 1 - preconditions
// load server and client keypairs
serverKeyPair, err = tls.X509KeyPair([]byte(serverCert), []byte(serverKey))
if err != nil {
t.Errorf("failed to load test server key pair: %v", err)
return
}
clientKeyPair, err = tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
t.Errorf("failed to load test client key pair: %v", err)
return
}
// Section 2 - preconditions
// setup server and clients
...
// Section 3
// test code, check if what you got is what you want
return
}
Trimming redundant code
Let’s glance through the snippet.
First, we remove the return at the end of the test. We don’t need it as the testing tool handles all the machinery for running tests and collecting statistics.
Also, it’s always good to see what staticcheck says about the code:
server_tls_test.go:315:2: redundant return statement (S1023)
Using short variable declaration
Next, we change the section where the test function sets up variables. We use the short variable declaration format (:=
) instead of separately creating variables with their default zero values.
func TestTCPoverTLSClient(t *testing.T) {
// Section 1
// load server and client keypairs
serverKeyPair, err := tls.X509KeyPair([]byte(serverCert), []byte(serverKey))
if err != nil {
t.Errorf("failed to load test server key pair: %v", err)
return
}
clientKeyPair, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
t.Errorf("failed to load test client key pair: %v", err)
return
}
// test code
}
Great. So far, we removed the obsolete return from the end of the test and opted for using the short var declaration format. Our test is shorter and easier to read.
Making sure preconditions are right
What if tls.X509KeyPair returns an error that is not nil? Well, this makes the var serverKeyPair unreliable. We shouldn’t use it at all.
serverKeyPair, err := tls.X509KeyPair([]byte(serverCert), []byte(serverKey))
The function sends us a signal (error) and warns us not to rely on the returned value. But we can’t start our server without serverKeyPair and clientKeyPair.
Without running the server, we can’t test its functionality (for example, sending a message to Modbus clients).
In the example, if we get an error when generating the keyPairs, the test function will exit on the return statement. But the Go testing package provides methods that do it for us - t.Fatal and t.Fatalf.
When the testing tool calls t.Fatal or t.Fatalf, it immediately marks the test as failed and moves to another test without wasting time.
func TestTCPoverTLSClient(t *testing.T) {
serverKeyPair, err := tls.X509KeyPair([]byte(serverCert), []byte(serverKey))
if err != nil {
t.Fatalf("failed to load test server key pair: %v", err)
}
clientKeyPair, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
t.Fatalf("failed to load test client key pair: %v", err)
}
...
}
Running tests with invalid preconditions
But what happens if we remove return statements and leave t.Errorf?
func TestTCPoverTLSClient(t *testing.T) {
serverKeyPair, err := tls.X509KeyPair([]byte(serverCert), []byte(serverKey))
if err != nil {
t.Errorf("failed to load test server key pair: %v", err)
}
clientKeyPair, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
t.Errorf("failed to load test client key pair: %v", err)
}
...
}
The Go test tool goes through the test. It uses invalid preconditions, collects all failures along the way, and at the end, marks the test as failed.
Sounds like a waste of time, wasted compute cycles, and broken logic. Isn’t it?
Let’s step back and think about the testing setup from another perspective.

Recall the last time you brought your car for an annual inspection.
What’s the precondition for testing lights, exhaust, and CO2 emissions? The working battery.
The battery needs to be in the car and be in good condition. That’s obvious. Without it, the engine won’t start. Inspecting lights and CO2 emissions will be impossible. The test fails the moment the mechanic turns the key to start the car.
Would you be surprised watching the mechanic spending 20-30 minutes inspecting all lights, brakes, emissions and more, without the engine working and then marking all checkpoints as failed?
What’s the takeaway?
- write your tests like a car mechanic - setup preconditions first, then check values and decide if the values you get are what you want
- clearly separate setting up preconditions from validating functionality
- fail fast (t.Fatal) if preconditions are not set up correctly