Unit testing Go code with mocks and dependency injection
Programming Snapshot – Go Testing
Developers cannot avoid unit testing if they want their Go code to run reliably. Mike Schilli shows how to test even without an Internet or database connection, by mocking and injecting dependencies.
Not continuously testing code is no longer an option. If you don't test, you don't know if new features actually work – or if a change adds new bugs or even tears open old wounds again. While the Go compiler will complain about type errors faster than scripting languages usually do, and strict type checking rules out whole legions of careless mistakes from the outset, static checks can never guarantee that a program will run smoothly. To do that, you need a test suite that exposes the code to real-world conditions and sees whether it behaves as expected at run time.
Ideally, the test suite should run at lightning speed so that developers don't get tired of kicking it off over and over again. And it should be resilient, continuing to run even while the Internet connection on the bus ride to work occasionally drops. So, if the tests open a connection to a web server or need a running database, this is very much out of line with the idea of fast independent tests.
However, since hardly any serious software just keeps chugging along by itself without a surrounding infrastructure, it is important for the test suite to take care of any dependencies on external systems and replace them with Potemkin villages. These simulators (aka "mocks") slip into the role of genuine communication partners for the test suite, accepting its requests and returning programmed responses, just as their real world counterparts would.
What Can Go Wrong?
Listing 1 [1] shows a small library with the Webfetch()
function, which expects a URL for test purposes and returns the content of the page hiding behind the URL. What could possibly go wrong when you're just fetching a website? First of all, the specified URL may not comply with the standardized format. Then there could be problems contacting the server: errors in DNS resolution, network time outs, or the server might just be taking a powernap. Or maybe the given URL does not refer to a valid document on the server, which then responds with a 404
, or it requests a redirect with a 301
, for example.
Listing 1
webfetch.go
01 package webfetcher 02 03 import ( 04 "fmt" 05 "io/ioutil" 06 "net/http" 07 ) 08 09 func Webfetch(url string) (string, error) { 10 resp, err := http.Get(url) 11 12 if err != nil { 13 return "", err 14 } 15 16 if resp.StatusCode != 200 { 17 return "", fmt.Errorf( 18 "Status: %d", resp.StatusCode) 19 } 20 21 defer resp.Body.Close() 22 23 body, err := ioutil.ReadAll(resp.Body) 24 if err != nil { 25 return "", fmt.Errorf( 26 "I/O Error: %s\n", err) 27 } 28 return string(body), nil 29 }
The code in Listing 1 checks for all of these potential errors and returns an error
type if it finds one. Once the client has finally tapped into the stream of incoming bytes from the network as of line 23, it can happen that the stream is suddenly interrupted because the network connection breaks down. A good client should field all of these cases, and a good test suite should verify that the client does so in all situations.
No Fuss
Now the test-suite might run on systems that don't have a reliable Internet connection – nothing is more annoying than a program that sometimes works properly and sometimes doesn't. To avoid such dependencies, test suites often replace external systems with small-scale responders. In what is known as mocking, simple test frameworks mimic certain capabilities of external systems in a perfectly reproducible manner. For example, a simplified local web server is used that only delivers static pages or only reports error codes.
Programs in Go can even run a server in the same process as the test suite, thanks to its quasi-simultaneous Go routines. With no need to start an external process, this is incredibly convenient, because the whole time-consuming and error-prone fuss about starting and, above all, shutting down external processes properly even in erroneous conditions is no longer required.
Conventions
Listing 2 checks if the Webfetch()
function from Listing 1 works and if the server delivers a text file as advertised. To do this, it defines the TestWebfetchOk()
function as of line 18. The function expects a pointer to Go's standard testing data structure of the testing.T
type as a parameter, which it later uses to report errors to the test suite.
Listing 2
webfetch_200_test.go
01 package webfetcher 02 03 import ( 04 "fmt" 05 "net/http" 06 "net/http/httptest" 07 "testing" 08 ) 09 10 const ContentString = "Hello, client." 11 12 func Always200(w http.ResponseWriter, 13 r *http.Request) { 14 w.WriteHeader(http.StatusOK) 15 fmt.Fprint(w, ContentString) 16 } 17 18 func TestWebfetchOk(t *testing.T) { 19 srv := httptest.NewServer( 20 http.HandlerFunc(Always200)) 21 content, err := Webfetch(srv.URL) 22 23 if err != nil { 24 t.Errorf("Error on 200") 25 } 26 27 if content != ContentString { 28 t.Errorf("Expected %s but got %s", 29 ContentString, content) 30 } 31 }
It should be noted that Go insists on sticking to conventions and stubbornly ignores anything that deviates from them. The names of all test files must end with _test.go
. So, it must be webfetch_200_test.go
and not, say, webfetch_test_200.go
, because otherwise the command go test
would silently ignore the file and therefore wouldn't find any tests to execute. On top of that, the names of the test suite's test routines must start with func Test<tXXX>
, otherwise Go won't recognize them as unit tests and won't run them at all.
And, finally, the "one package per directory" rule always applies in Go: All three Go programs, the webfetch.go
library and the two files with the unit tests, all show the package webfetcher
package at the beginning of their code.
The properly defined TestWebfetchOk()
function in Listing 2 becomes part of the test suite, and the error checks in the if
statements in lines 23 and 27 become its test cases. Line 23 verifies that the server sent an OK status code of 200
, and line 27 compares the received string with the mock server's hard-coded string, specified in line 10 ("Hello, client."
). In both cases, the test suite says nothing if everything runs smoothly and only reports any errors that may occur.
If you prefer your test routines to be a bit more talkative, you can use the t.Logf()
function before each test case to display a message hinting at the test about to be performed; you can also generate some output in case of success. As implemented in Listing 2, the test suite called in verbose mode with
go test -v
does not give you any details on the individual test cases if everything passes, except that it reports the executed test functions (Figure 1). But if something were to go wrong, the error messages output with t.Errorf()
would rear their ugly heads.
The Always200()
handler in line 12 of Listing 2 defines the behavior of the built-in test web server. No matter what the incoming type *http.Request
request looks like, it simply returns a status code of http.StatusOK
(that is, 200
) in the header of the HTTP response and adds the "Hello, client."
string as the page content. Line 19 starts the actual web server from the httptest
package, converts the previously defined handler to the http.HandlerFunc
type, and hands it over to the server as a function.
The server's URL
attribute specifies the host and port on which the new server is listening, which Line 21 hands over as a URL to the Webfetch()
client function to be tested. From the client's point of view, a status code 200 and the previously set string are returned as expected, and the test suite does not raise an error.
Buy this article as PDF
(incl. VAT)
Buy Linux Magazine
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters
Support Our Work
Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.
News
-
Fedora 41 Released with New Features
If you're a Fedora fan or just looking for a Linux distribution to help you migrate from Windows, Fedora 41 might be just the ticket.
-
AlmaLinux OS Kitten 10 Gives Power Users a Sneak Preview
If you're looking to kick the tires of AlmaLinux's upstream version, the developers have a purrfect solution.
-
Gnome 47.1 Released with a Few Fixes
The latest release of the Gnome desktop is all about fixing a few nagging issues and not about bringing new features into the mix.
-
System76 Unveils an Ampere-Powered Thelio Desktop
If you're looking for a new desktop system for developing autonomous driving and software-defined vehicle solutions. System76 has you covered.
-
VirtualBox 7.1.4 Includes Initial Support for Linux kernel 6.12
The latest version of VirtualBox has arrived and it not only adds initial support for kernel 6.12 but another feature that will make using the virtual machine tool much easier.
-
New Slimbook EVO with Raw AMD Ryzen Power
If you're looking for serious power in a 14" ultrabook that is powered by Linux, Slimbook has just the thing for you.
-
The Gnome Foundation Struggling to Stay Afloat
The foundation behind the Gnome desktop environment is having to go through some serious belt-tightening due to continued financial problems.
-
Thousands of Linux Servers Infected with Stealth Malware Since 2021
Perfctl is capable of remaining undetected, which makes it dangerous and hard to mitigate.
-
Halcyon Creates Anti-Ransomware Protection for Linux
As more Linux systems are targeted by ransomware, Halcyon is stepping up its protection.
-
Valve and Arch Linux Announce Collaboration
Valve and Arch have come together for two projects that will have a serious impact on the Linux distribution.