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
-
Systemd Fixes Bug While Facing New Challenger in GNU Shepherd
The systemd developers have fixed a really nasty bug amid the release of the new GNU Shepherd init system.
-
AlmaLinux 10.0 Beta Released
The AlmaLinux OS Foundation has announced the availability of AlmaLinux 10.0 Beta ("Purple Lion") for all supported devices with significant changes.
-
Gnome 47.2 Now Available
Gnome 47.2 is now available for general use but don't expect much in the way of newness, as this is all about improvements and bug fixes.
-
Latest Cinnamon Desktop Releases with a Bold New Look
Just in time for the holidays, the developer of the Cinnamon desktop has shipped a new release to help spice up your eggnog with new features and a new look.
-
Armbian 24.11 Released with Expanded Hardware Support
If you've been waiting for Armbian to support OrangePi 5 Max and Radxa ROCK 5B+, the wait is over.
-
SUSE Renames Several Products for Better Name Recognition
SUSE has been a very powerful player in the European market, but it knows it must branch out to gain serious traction. Will a name change do the trick?
-
ESET Discovers New Linux Malware
WolfsBane is an all-in-one malware that has hit the Linux operating system and includes a dropper, a launcher, and a backdoor.
-
New Linux Kernel Patch Allows Forcing a CPU Mitigation
Even when CPU mitigations can consume precious CPU cycles, it might not be a bad idea to allow users to enable them, even if your machine isn't vulnerable.
-
Red Hat Enterprise Linux 9.5 Released
Notify your friends, loved ones, and colleagues that the latest version of RHEL is available with plenty of enhancements.
-
Linux Sees Massive Performance Increase from a Single Line of Code
With one line of code, Intel was able to increase the performance of the Linux kernel by 4,000 percent.