This is a creation in Article, where the information may have evolved or changed.
Note:this Post is originally written for the Go Advent series, but I discovered that a post with almost exactly the Same subject (and even similar code!) already planned:) That ' s amazing.
Golang is often used for writing microservices and various backends. Often These type of software do some computation, read/write data on external storage and expose it ' s API via HTTP Handler S. All this functionality are remarkably easy to implement in Go and, especially if you ' re creating 12factor-compatible App , Go is your friend here.
This functionality was also easy to test using built-in Go testing tooling. But here's the catch-unit testing or small tests doesn ' t guarantee that your service is working correctly. Even if you simply want to test your HTTP response codes that you had to inject dependencies first and connect your code to T He external resources or storage. At the probably realize you need to write a proper integration test, which include not only your code but AL L dependent resources as well.
But what do you without inventing your own scripts and harness code for mocking and starting services? How to do it as easy-to-use as a normal ' go test ' workflow? How to deal with setting up migrations and schemas for you databases? Finally, how does it cross-platform, so can easily run those tests on your Macbook as well as in your CI node?
Let me show one of the possible solutions I with a number of services for quite a long time. It leverages the power of Docker isolation and comfort of go test tooling, and thus very easy-to-use and, with little Effo RTS, gives you truly cross-platform integration testing.
As an example I'll take the simple go-based webservice, which are often may be sufficient for rest-backends:
- Rest-service based on gin framework
- Data storage-external MySQL Database
- Goose Tool for Migrations
Docker
So, yes, we'll use Docker to handle all external dependencies (MySQL database in our case), and that's exactly the case where Docker shines. Nowadays Internet is full of articles and talks telling the Docker is isn't a ' silver bullet ', and putting a lot of critici SM on many Docker use cases. Of course, they ' re absolutely right and many of their points is valid, but in this particular case it's exactly the case Where you should use Docker. It gives us everything we need-repeatability, isolation, speed, and portability.
Let's start by creating Dockerfile for our dependency service-mysql database. Normally you would use official MySQL Docker image, but we had to wind up migrations with goose, so we ' d better off creat ing our custom MySQL Debian Image:
from debianenv debian_frontend noninteractiverun apt-get updaterun apt-get install-y Mysql-serverrun sed-i-e«s/^bind-address\s*=\s*127.0.0.1/bind-address = 0.0.0.0/»/etc/mysql/my.cnfrun apt-get Install-y golang git ca-certificates gccenv gopath/rootrun go get bitbucket.org/liamstask/goose/cmd/gooseadd. /dbrun \service MySQL start && \sleep && \while true; Do Mysql-e«select 1»&>/dev/null; [$?-eq 0] && break; Echo-n "."; Sleep 1; Do && \mysql-e«grant all on * * to ' root ' @ '% '; FLUSH privileges;»&& \mysql-e«create DATABASE mydb DEFAULT COLLATE utf8_general_ci;»&& \/root/bin/goos E-env=production up && \service mysql stopexpose 3306CMD [«mysqld_safe»]
Then we build our image with docker build -t mydb_test .
command and run it with docker run -p 3306:3306 mydb_test
. The resulting container'll has a fresh actual database instance with the latest migrations applied. Once the image is built it takes less than a second to start this container.
The actual name of container and database is not important here, so we use mydb
and mydb_test
-simply a convention.
Go Tests
Now, it's time to write some Go code. Remember, we want our test to is portable and issued with go test
command only. Let's start our Service_test.go:
// +build integrationpackage mainimport ( "testing")
The We place build tag is sure this test would run only if integration
explicitly asked with --tags=integration
flag. Yes, the test itself is fast, but still requires a external tool (Docker), so we ' d better separate integration tests and Unit tests.
By the the-the-the-could, we protect in with testing. Short flag, but the behavior is opposite in this Case-long tests run by default.
if testing.Short() { t.Skip("skipping test in short mode.")}
Running Docker Container
Before running our tests, we need to start our dependencies. There is a few packages to work with Docker Remote APIs for Go, I'll use the one from Fsouza, which I successfully using For quite a long time. Install it with:
go get -u github.com/fsouza/go-dockerclient
To start the container, we had to write following code:
client, err := docker.NewClientFromEnv()if err != nil { t.Fatalf("Cannot connect to Docker daemon: %s", err)}c, err := client.CreateContainer(createOptions("mydb_test"))if err != nil { t.Fatalf("Cannot create Docker container: %s", err)}defer func() { if err := client.RemoveContainer(docker.RemoveContainerOptions{ ID: c.ID, Force: true, }); err != nil { t.Fatalf("cannot remove container: %s", err) }}()err = client.StartContainer(c.ID, &docker.HostConfig{})if err != nil { t.Fatalf("Cannot start Docker container: %s", err)}
CreateOptions () is a helper function returning struct with container creating options. We Pass our Docker container name to that function.
func сreateOptions(dbname string) docker.CreateContainerOptions { ports := make(map[docker.Port]struct{}) ports["3306"] = struct{}{} opts := docker.CreateContainerOptions{ Config: &docker.Config{ Image: dbname, ExposedPorts: ports, }, } return opts}
After the We need to write code which would wait for the DB to start, the Extract IP address for connection, the form DSN for database /sql driver and open the actual connection:
// wait for container to wake upif err := waitStarted(client, c.ID, 5*time.Second); err != nil { t.Fatalf("Couldn't reach MySQL server for testing, aborting.")}c, err = client.InspectContainer(c.ID)if err != nil { t.Fatalf("Couldn't inspect container: %s", err)}// determine IP address for MySQLip = strings.TrimSpace(c.NetworkSettings.IPAddress)// wait MySQL to wake upif err := waitReachable(ip+":3306", 5*time.Second); err != nil { t.Fatalf("Couldn't reach MySQL server for testing, aborting.")}
Here we wait for the actions to Happen:first are to get the network inside container up, so we can obtain it ' s IP address, and Second, is MySQL service being actually started. Waiting functions is a bit tricky, so here they is:
//waitreachable waits for Hostport-became reachable for the maxwait Time.func waitreachable (host Port string, maxwait time. Duration) Error {done: = time. Now (). ADD (maxwait) for time. Now (). Before (done) {c, err: = Net. Dial ("TCP", hostport) if Err = = nil {c.close () return nil} time. Sleep (Time.millisecond)} return FMT. Errorf ("Cannot connect%v for%v", Hostport, maxwait)}//waitstarted waits for a container to start for the maxwait time.f UNC waitstarted (client *docker. Client, id string, maxwait time. Duration) Error {done: = time. Now (). ADD (maxwait) for time. Now (). Before (done) {c, err: = client. Inspectcontainer (ID) if err! = nil {break} if c.state.running {return nil } time. Sleep (Time.millisecond)} return FMT. Errorf ("Cannot start container%s for%v", ID, maxwait)}
Basically, it ' s enough to work with our container, but here's another issue comes in-if you run MacOS X or Windows, you Use Docker via the proxy Vsan with tiny Linux, docker-machine
(or its predecessor, boot2docker
). It means should use Docker-machine's IP address and not real container IP, which are not exposed outside of the docker- Host Linux VMs.
Tuning for portability
Again, let's just write code to accomplish, as it ' s quite trivial:
//Dockermachineip returns IP of Docker-machine or Boot2docker VM instance.////If docker-machine or Boot2docker is running and have IP, it'll be used to//connect to dockerized Services (MySQL, etc).////Basically, it ad DS support for MacOS X and Windows.func Dockermachineip () string {//Docker-machine are a modern solution for Docker in MacOS X. Try to detect it, with fallback to boot2docker var dockermachine bool Machine: = OS. Getenv ("Docker_machine_name") if machine! = "" {Dockermachine = true} var buf bytes. Buffer var cmd *exec. cmd if dockermachine {cmd = exec. Command ("Docker-machine", "IP", machine)} else {cmd = exec. Command ("Boot2docker", "IP")} cmd. Stdout = &buf If err: = cmd. Run (); Err! = Nil {//Ignore error, as it's perfectly OK on Linux return "} return BUF. String ()}
For working with Docker-machine we'll also need to pass the port forwarding configuration in Createcontaineroptions.
At this point, the amount of supporting code becomes quite notable, and it's better to move all Docker related code into s Eparate a subpackage, perhaps in internal/directory. Let ' s name it internal/dockertest
. The source of this package can is found here.
Running from Tests
Now, all we need are to import our internal/dockertest
subpackage and start MySQL with a single line:
// start db in docker containerdsn, deferFn, err := dockertest.StartMysql()if err != nil { t.Fatalf("cannot start mysql in container for testing: %s", err)}defer deferFn()
Pass to dsn
SQL. Open () or your own service init function, and your code would connect to the database inside the container. Note, that Startmysql () returns also a defer function, which'll properly stop and remove container. Our test code knows nothing about underlying mechanisms. It just works as if it was a normal MySQL resource.
Testing HTTP Endpoints
Next step is to test http-endpoints. We may want to test response codes, proper error messages, expected headers or data format and so on. And, following our desire to not depend on any external testing scripts, we want to run all the tests within the Go code. And Go allows us to doing so using the Net/http/httptest package.
Honestly, was one of httptest
the most surprising things on Go, when I first saw it. Net/http design was quite unusual and El Egant for me, but Httptest looked like a killer feature for testing HTTP services. It leverages the power of interfaces in Go, and particularly, the HTTP. Responsewriter interface to achieve in-memory round-trip of HTTP requests. We don ' t need to ask OS to open ports, deal with permissions and busy ports-it ' s all in memory.
And as soon as gin framework implements HTTP. Handler interface, which looks like this:
type Handler interface { ServeHTTP(ResponseWriter, *Request)}
We can use it transparently with httptest. I'll also use amazing Goconvey testing Framework, which implements Behaviour-driven testing for Go, and fully compatible With the default go test
workflow.
func newserver (db *sql. DB) *gin. Engine {r: = gin. Default () R.use (cors. Middleware (cors. options{})//More middlewares ...//Health Check R.get ("/ping", ping)//CRUD resources usersres: = & ; USERSRESOURCE{DB:DB}//Define routes API: = R.group ("/api") {v1: = API. Group ("/v1") {rest. CRUD (v1, "/users", Usersres)}} return R}...R: = NewServer (db) Convey ("Users endpoints should respond correct Ly ", T, func () {convey (" User should return empty list ", Func () {//It ' s safe to ignore error here, because we ' Re manually entering URL req, _: = http. Newrequest ("GET", "http://localhost/api/v1/users", nil) W: = Httptest. Newrecorder () r.servehttp (W, req) so (W.code, shouldequal, http. Statusok) Body: = Strings. Trimspace (w.body.string ()) So (Body, Shouldequal, "[]")})})
Goconvey has also a astonishing web UI, I guarantee you'll start writing more tests just to see this nice blinking "PAS S "message! :)
And now, after your get the idea, we can add more tests for testing basic CRUD functionality for our simple service:
convey ("Create should return ID of a newly created user", Func () {User: = &user{name: "Test User "} data, err: = json. Marshal (user) So (err, shouldbenil) buf: = bytes. Newbuffer (data) req, err: = http. Newrequest ("POST", "Http://localhost/api/v1/users", buf) so (err, shouldbenil) W: = Httptest. Newrecorder () r.servehttp (W, req) so (W.code, shouldequal, http. Statusok) Body: = Strings. Trimspace (w.body.string ()) So (Body, shouldequal, "1")}) convey ("List should return one user with name ' Test user '", func () {req, _: = http. Newrequest ("GET", "http://localhost/api/v1/users", nil) W: = Httptest. Newrecorder () r.servehttp (W, req) so (W.code, shouldequal, http. Statusok) Body: = W.body.bytes () var users []*user Err: = json. Unmarshal (body, &users) so (err, shouldbenil) User: = &user{id:1, Name: "Test user",} So (len (users), shouldequal, 1) so (Users[0], shouldresemble, user)})
Conclusion
As you'll see, Go isn't only make testing a lot easiers but also make use of the BDD and TDD methodologies very easy to follow and opens new possibilities for Cross-platform Integration-and acceptance-testing.
This example provided are simplified on purpose, but it's based on the real production code which are being tested in T His to more than 1.5 years and survived a number of refactorings and migrations ' updates. On my Macbook Air, the whole test, from start to end (compile code, run Docker container in Docker-machine and test ~35 HT TP requests, shut down the container) it takes about 3 seconds. On native Linux system It's obviously a lot faster.
One may ask why is publish this code as a separate library, and make the whole task (and article) even shorter. The point is, and every different service there may be a different set of service connections, different USAG e patterns and so on. And what are really important is that with Go it's so easy-to-write this harness code for your needs, that's you don ' t has a N Excuse not to does this. Whether you need many similar containers in parallel (probably, you'll need to randomize exposed ports), or you are in Terconnect some services before starting them-you just write in Go, hiding all the complexity from the actual testing Co De.
and always write tests! There is not excuse not to write them anymore.
Upd:after writing the article, discovered the package dockertest by Aeneas Rekkas (@_aeneasr), which does almost exactly The same as a code in this article, and looks pretty solid. Don ' t miss it out!