Test your code

SHODO

Hello! I'm Nathan.

What about you?

The next four hours

Schedule

Morning session

  • 9h: Session start
  • 10h: 5 minutes break
  • 11h: 10 minutes break
  • 12h: 5 minutes break
  • 13h: Session ends

Afternoon session

  • 14h: Session start
  • 15h: 5 minutes break
  • 16h: 10 minutes break
  • 17h: 5 minutes break
  • 18h: Session ends

What do you expect

from this training?

Content

  • Introduction to tests
  • Unit testing in Go
  • Mock
  • Integration tests

A four-hours training means ...

  • A lot of resources will be provided to go further outside of the training session
  • Some topics are excluded from this training session
  • Sometimes, it's my point of view
  • I'm available for any question following the session: nathan.castelein@shodo-lille.io

Prerequisites

  • Go v1.22
  • Visual Studio Code
  • Git

Introduction to tests

Why testing your code?

Software testing is a crucial activity in the software development life cycle that aims to evaluate and improve the quality of software products. Thorough testing is essential to ensure software systems function correctly, are secure, meet stakeholders’ needs, and ultimately provide value to end users.

 

https://www.computer.org/resources/importance-of-software-testing  

Testing your code

When it is time to test your code, you can have different approaches.

 

Martin Fowler provided a good overview of software testing thanks to a graphical approach: the Test Pyramid.

The Test Pyramid

The cost of Test Pyramid

Unit tests

Unit tests aim to validate the behaviour of a small piece of software, a "code unit". This "code unit" can be a method, a class, a function, ...

Integration tests

An integration test determines if some or all application components, after being unit tested, still operate properly while working together.

End to end tests

End to end tests check that all components, from the UI to the external dependencies (databases, external REST APIs, etc.) are working together.

Aiming for more quality

Automate your tests

Writing tests is a good start, but the good practice is to automate the run of your tests.

Unit tests should run automatically during your development process.

Integration and end to end tests should be launched through your continuous delivery.

Add performance and load tests

Ensure your application is working for a given numbers of users.

Define proper objectives, detect your SPOF, know the limits of your application.

Detect performances regressions between two versions of your application.

What about this training session?

In this training session, we will go through:

  • Unit testing, a lot
  • Some examples of integration testing

Unit testing

Unit testing: tests are F.I.R.S.T.

Unit tests should follow the F.I.R.S.T. approach:

  • F: fast
  • I: isolated, independent
  • R: repeatable
  • S: self-verifying
  • T: timely, thorough

F, for fast

The feedback loop is a key point for good unit tests.

If launching unit tests is a pain point due to the execution time, then they will be skipped for productivity reasons.

 

Unit tests are not supposed to go over milliseconds, to ensure they are included during development and regularly launched.

I, for isolated, independent

A single test must be autonomous, and must not depend of other tests execution.

 

We should be able to run all tests in parallel, without any order of execution.

R, for repeatable

A unit test must be infinitely repeatable, on different environments.

 

The result must remain the same on a same codebase, whatever the environment.

 

If an application relies on the environment (like using time, etc.), we should substitute with a proper abstraction.

S, for self-verifying

The result of a test must be objectifiable. Either it's a positive result, either it's a failure.

 

In case of failure, the result must readable and understandable by itself.

 

Reading a failure must not require a deep human intervention.

T, for timely, thorough

Develop tests as opportunities: write use cases you think of, use bugs and issues to create new tests then fix your code. Waiting that your feature is written before thinking about tests makes your code harder to test.

 

One test should cover a maximum of one use case. Separate your use cases in multiple tests: nominal cases, borderline cases, error cases, etc.

Follow the F.I.R.S.T. approach

Following this approach will help you to write efficient tests. Ignoring it can result to write code hard to test or to maintain, or tests that are hard to launch on a regular basis.

 

More information: https://dzone.com/articles/first-principles-solid-rules-for-tests

Or chapter 8 of this (wonderful) book: https://www.dunod.com/sciences-techniques/software-craft-tdd-clean-code-et-autres-pratiques-essentielles

Unit testing in Go

Introduce the testing package!

Go provides a standard package for writing tests: https://pkg.go.dev/testing

 

This package is self-sufficient for quite all use cases. It's a simple package, but very powerful, combined with the go test command and its options.

Testing package in a nutshell

Test Write tests for your code (unit tests, integration tests)
Fuzzing Write fuzzing tests for your code
Benchmark Write benchmarks for your code

Writing your first test

package math

import "testing"

func TestSum(t *testing.T) {
    got := Sum(1, 2, 3)
    if got != 6 {
        t.Errorf("Sum(1, 2, 3) = %d; want 6", got)
    }
}

All tests must be written in a _test.go file.

package math

func Sum(numbers ...int) int {
	sum := 0
	for _, number := range numbers {
    	sum += number
    }
    return sum
}

sum.go

sum_test.go

The *testing.T type

https://pkg.go.dev/testing#T

The testing.T type provides functions to:

  • Log information during the test run
  • Fail a test
  • Skip a test

Logging and failing

Log

  • t.Log("...") / t.Logf("..."): logs will be printed only if the test fails or with go test -v

Fail

  • t.Fail(): marks the function as having failed but continues execution
  • t.FailNow(): marks the function as having failed and stops its execution

Log & fail

  • t.Error("...") / t.Errorf("..."): t.Log & t.Fail
  • t.Fatal("...") / t.Fatalf("..."): t.Log & t.FailNow

Skipping

Skip

  • t.SkipNow(): marks the test as having been skipped and stops its execution
  • t.Skip("...") / t.Skipf("..."): t.Log & t.SkipNow
  • testing.Short(): check if we are in a short mode

If a test fails and is then skipped, it's still considered as failed.

func TestAVeryLongTest(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode.")
    }
    ...
}

Run your tests

$ go test

$ go test -v

$ go test -run TestStuff

$ go test -skip TestStuff

$ go test -short

$ go test -timeout 1m

$ go test ./...

Go test is your weapon to run your tests!

$ go install gotest.tools/gotestsum@latest

$ gotestsum --watch
Watching 3 directories. Use Ctrl-c to to stop a run or exit.

Used to extend the go test output format. Run go test -json and change the output. Also provide a watcher!

Test FizzBuzz

Write 4 unit tests to test the behaviour of the FizzBuzz function:

  • Return the same number when not multiple of 3 or 5
  • Return Fizz when multiple of 3 but not 5
  • Return Buzz when multiple of 5 but not 3
  • Return FizzBuzz when multiple of 3 and 5
file/to/open.go
$ go test .

File to open:

Test your code:

unit/fizzbuzz/fizzbuzz_test.go

Test FizzBuzz

func TestShouldReturnSameNumberWhenItIsNotMultipleOfThreeOrFive(t *testing.T) {
	if result := Fizzbuzz(1); result != "1" {
		t.Errorf("FizzBuzz(1) = %s; want 1", result)
	}
}

func TestShouldReturnFizzWhenNumberIsMultipleOfThree(t *testing.T) {
	if result := Fizzbuzz(3); result != "Fizz" {
		t.Errorf("FizzBuzz(3) = %s; want Fizz", result)
	}
}

Testing: the AAA pattern

The AAA pattern

While writing tests, it becomes quite a standard to divide your test method into three sections:

  • Arrange
  • Act
  • Assert

The AAA pattern

Arrange Set up the conditions for your test. This might involve creating objects, setting up variables or anything else that’s required for your test.
Act This is where you actually execute the code that you are testing.
Assert Verify that the code you’re testing behaves as expected. This might involve checking the value of a variable, or verifying that a certain method was called.

Test FizzBuzz

func TestShouldReturnSameNumberWhenItIsNotMultipleOfThreeOrFive(t *testing.T) {
	// Arrange
	number := 1

	// Act
	result := fizzbuzz.Fizzbuzz(number)

	// Assert
	if result != "1" {
		t.Errorf("FizzBuzz(1) = %s; want 1", result)
	}
}

Back to FizzBuzz tests

You wrote four methods to unit test the different behaviours of the Fizzbuzz function.

A bit redundant, isn't it?

 

For this kind of situation, you can have a look on the table-driven tests pattern!

Table-Driven Tests

Table-driven tests

Table-driven is a test pattern to make tests easier to write when your tests differ in the Arrange part, but not in the Act and Assert part.

 

To do so, we will create a temporary structure to hold input values and expected values, then declare a slice to hold all use cases.

 

https://dave.cheney.net/2019/05/07/prefer-table-driven-tests

Table-driven tests

func TestSum(t *testing.T) {
	// Arrange
    type test struct {
        input 		[]int
        expected   	int
    }

    tests := []test{
        {input: []int{1, 2, 3}, expected: 6},
        {input: []int{0, 0, 1}, expected: 1},
        {input: []int{5, 5}, expected: 10},
        {input: []int{}, expected: 0},
    }

    for _, testcase := range tests {
    	// Act
        got := Sum(testcase.input...)
        
        // Assert
        if got != testcase.expected {
            t.Fatalf("expected: %v, got: %v", testcase.expected, got)
        }
    }
}

Test FizzBuzz with TDT

Rewrite the 4 unit tests to a single test, using Table-Driven Tests pattern.

file/to/open.go
$ go test -run TestFizzBuzzWithTableDrivenTest

File to open:

Test your code:

unit/fizzbuzz/fizzbuzz_tdt_test.go

Test FizzBuzz with TDT

func TestFizzBuzzWithTableDrivenTest(t *testing.T) {
	// Arrange
	type test struct {
		input    int
		expected string
	}

	tests := map[string]test{
		"should return same number when it is not multiple of three or five": {
			input:    1,
			expected: "1",
		},
		"should return Fizz when number is multiple of three": {
			input:    3,
			expected: "Fizz",
		},
		"should return Buzz when number is multiple of five": {
			input:    5,
			expected: "Buzz",
		},
		"should return FizzBuzz when number is multiple of three and five": {
			input:    30,
			expected: "FizzBuzz",
		},
	}

...

Test FizzBuzz with TDT

func TestFizzBuzzWithTableDrivenTest(t *testing.T) {
	...

	// Act
	for name, test := range tests {
		result := Fizzbuzz(test.input)

		// Assert
		if result != test.expected {
			t.Fatalf("%s failed, expected %s, got %s", name, test.expected, result)
		}
	}
}

What if FizzBuzz is a long process?

Let's now imagine that FizzBuzz is a long process.

 

In your test, change the call to FizzBuzz to LongFizzBuzz then run go test!

func LongFizzbuzz(input int) string {
	time.Sleep(500 * time.Millisecond)
	return Fizzbuzz(input)
}

Two seconds later

By default, go test runs each test one by one, without parallelism.

But if you properly followed the F.I.R.S.T. approach, especially the Independent part, then your tests can be run in parallel!

 

The same issue comes with the table-driven tests, which are run sequentially.

The testing package provides a function to run tests in parallel.

Running tests in parallel

https://pkg.go.dev/testing#T.Parallel

 

Parallel signals that this test is to be run in parallel with (and only with) other parallel tests.

func TestShouldReturnSameNumberWhenItIsNotMultipleOfThreeOrFive(t *testing.T) {
    t.Parallel()
    
	// Arrange
	number := 1
	// Act
	result := fizzbuzz.Fizzbuzz(number)
	// Assert
	if result != "1" {
		t.Errorf("FizzBuzz(1) = %s; want 1", result)
	}
}

Running table-driven tests in parallel?

The Parallel() function can be used inside a test to declare parallelism.

 

How to use parallelism with table-driven tests?

Run sub-tests

https://pkg.go.dev/testing#T.Run

 

Run runs a function as a subtest. It runs the function in a separate goroutine and blocks until the function returns or calls t.Parallel to become a parallel test.

func TestWithSubTests(t *testing.T) {
    t.Run("first test", func(t *testing.T) {
    	t.Parallel()
        // do some stuff
    })
    
    t.Run("second test", func(t *testing.T) {
        t.Parallel()
        // do some stuff
    })
}

Parallelism with go test

Once your code has been written to be executed in parallel, you can provide the -parallel n flag with the go test command.

This allows to manage parallel execution of tests written with t.Parallel().

 

By default, the flag value is set to the value of the GOMAXPROCS env variable. Providing a value higher than the max number of processors of your machine can result to CPU contention.

Adding sub-tests

Add sub-tests in the Table-Driven tests with t.Run and t.Parallel function.

file/to/open.go
$ go test -run TestFizzBuzzWithTableDrivenTestAndParallelism

File to open:

Test your code:

unit/fizzbuzz/fizzbuzz_tdt_run_test.go

Adding sub-tests

func TestFizzBuzzWithTableDrivenTestAndParallelism(t *testing.T) {
	...
    
    // Act
	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			result := LongFizzbuzz(test.input)

			// Assert
			if result != test.expected {
				t.Fatalf("%s failed, expected %s, got %s", name, test.expected, result)
			}
		})
	}
}

Does it work properly?

Let's add some logs to check the behaviour.

 

Depending on your Go version, we will have some surprises...

func TestFizzBuzzWithTableDrivenTestAndParallelism(t *testing.T) {
	...
        	t.Logf("executing test %s", name)
	...
}
func TestFizzBuzzWithTableDrivenTestAndParallelism(t *testing.T) {
	...
    
    // Act
	for name, test := range tests {
		currentTest := test
		currentName := name
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			result := fizzbuzz.LongFizzbuzz(currentTest.input)

			// Assert
			if result != currentTest.expected {
				t.Fatalf("%s failed, expected %s, got %s", currentName, currentTest.expected, result)
			}
		})
	}
}

An issue has been fixed in the latest version of Go, the Go 1.22.

 

You can check all information here: https://go.dev/wiki/LoopvarExperiment

Clojures and goroutines

Coverage

Test coverage

In software engineering, code coverage is a percentage measure of the degree to which the source code of a program is executed when a particular test suite is run. A program with high test coverage has more of its source code executed during testing, which suggests it has a lower chance of containing undetected software bugs compared to a program with low test coverage. 

Test coverage

$ go test -cover
ok      github.com/nathancastelein/go-course-test/unit/fizzbuzz 2.385s  coverage: 100.0% of statements

$ go test -coverprofile=cover.out

$ go tool cover -html="cover.out"

Go test provides an easy way to generate coverage.

cover.html

Visual Studio Code can be configured to show coverage in the IDE.

Coverage

The coverage is an indicator of the part of the code which is unit tested.

 

Aiming for a good coverage percentage can be a good quality measurement, but aiming for 100% is a non-sense.

 

Write tests because you need them, not to only increase the coverage. 

Fuzz testing

Fuzz testing

Fuzz testing or fuzzing is an automated software testing method that injects invalid, malformed, or unexpected inputs into a system to reveal software defects and vulnerabilities. A fuzzing tool injects these inputs into the system and then monitors for exceptions such as crashes or information leakage.

 

Fuzz testing has been introduced in Go 1.18: https://go.dev/doc/security/fuzz/

Tutorial: https://go.dev/doc/tutorial/fuzz

Fuzz testing

There are two modes of running fuzz tests: as a unit test (with go test) or with fuzzing (go test -fuzz=FuzzXXX).

 

Running with fuzzing will generate many different inputs to inject in your function, to test strange behaviours.

Fuzz testing

func FuzzReverse(f *testing.F) {
    f.Add("Hello world")
    f.Add("This is a corpus")
    
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

Fuzz testing

go test

 

Run fuzzing only with the corpus.

go test -fuzz=Fuzz

 

Run fuzzing with the corpus and with generated inputs.

 

Stops on an error.

Add -fuzztime=5s to stop after a given time.

Fuzzing in FizzBuzz?

How can we use fuzzing to detect issues on FizzBuzz?

Fuzzing in FizzBuzz

Write the FuzzFizzbuzz unit test with fuzzing to check that:

  • Either output is one of Fizz, Buzz or FizzBuzz
  • Either output can be converted to a number and is the input number
file/to/open.go
$ go test -run=Fuzz -fuzz=Fuzz -fuzztime=5s

File to open:

Test your code:

unit/fizzbuzz/fizzbuzz_fuzzing_test.go

Fuzz testing FizzBuzz

func FuzzFizzbuzz(f *testing.F) {
	f.Add(1) // Use f.Add to provide a seed corpus
	f.Add(3)
	f.Add(5)
	f.Add(30)

	f.Fuzz(func(t *testing.T, number int) {
		result := Fizzbuzz(number)
		switch result {
		case "Fizz", "Buzz", "FizzBuzz":
			return
		default:
			numberFromString, err := strconv.Atoi(result)
			if err != nil {
				t.Fatalf("got invalid result for input %d: %s (error %s)", number, result, err)
			}

			if numberFromString != number {
				t.Fatalf("number should not be different: expected %d got %d", number, numberFromString)
			}
		}
	})
}

Fuzz testing FizzBuzz

$ go test -fuzz=Fuzz -fuzztime=5s
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 776964 (258970/sec), new interesting: 4 (total: 8)
fuzz: elapsed: 5s, execs: 1289080 (248523/sec), new interesting: 4 (total: 8)
PASS
ok      github.com/nathancastelein/go-course-test/solution/unit/fizzbuzz        6.048s

Mock

In 2006, Martin Fowler wrote about "Test Double". Test Double is a generic term for any case where you replace a production object for testing purposes.

There are various kinds of double:

  • Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
  • Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example).

Mocks, or Test Double?

  • Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
  • Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
  • Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don't expect and are checked during verification to ensure they got all the calls they were expecting.

You probably never heard about Test Double, but more often about mocks, or mocking. Mocks are a part of Test Double, but is more and more used to replace the term "Test Double".

Mocks and Go

Let's now have a look on how to use mocks to test code with dependancies to:

  • HTTP API
  • SQL

Mock HTTP

httptest

By the standard: httptest

https://pkg.go.dev/net/http/httptest

Go provides a standard package for http unit tests: httptest

This library provides tools to:

  • Test http handlers: create http.Request and http.ResponseWriter recorder to test a handler
  • Test requests to an external server: create http.Server as an endpoint for your calls

Test http handlers

func HelloWorldHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello world!")
}

We want to test this handler:

Test http handlers

func TestHelloWorldHandler(t *testing.T) {
	// Arrange
	request := httptest.NewRequest(http.MethodGet, "/", nil)
	responseRecorder := httptest.NewRecorder()

	// Act
	HelloWorldHandler(responseRecorder, request)

	// Assert
	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected status code %d, got %d", http.StatusOK, responseRecorder.Code)
	}

	body, err := io.ReadAll(responseRecorder.Body)
	if err != nil {
		t.Fatalf("got an error while reading body: %s", err)
	}

	if string(body) != "Hello world!\n" {
		t.Fatalf("expected body Hello world!, got %s", string(body))
	}
}

Test http requests

func HelloHTTPCall(url string) (string, error) {
	res, err := http.Get(url + "/hello")
	if err != nil {
		return "", err
	}

	body, err := io.ReadAll(res.Body)
	defer res.Body.Close()
	if err != nil {
		return "", err
	}

	return string(body), nil
}

We want to test this function:

Test http requests

func TestHelloHTTPCall(t *testing.T) {
	// Arrange
	testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			t.Fatalf("expected method GET, got %s", r.Method)
		}

		if r.URL.Path != "/hello" {
			t.Fatalf("expected path /hello, go %s", r.URL.Path)
		}

		fmt.Fprintln(w, "Hello world!")
	}))
	defer testServer.Close()

	// Act
	result, err := HelloHTTPCall(testServer.URL)

	// Assert
	if err != nil {
		t.Fatalf("got an error while making call: %s", err)
	}

	if result != "Hello world!\n" {
		t.Fatalf("expected body Hello world!, got %s", result)
	}
}

gock

  1. Intercepts any HTTP outgoing request via http.DefaultTransport or custom http.Transport used by any http.Client.
  2. Matches outgoing HTTP requests against a pool of defined HTTP mock expectations in FIFO declaration order.
  3. If at least one mock matches, it will be used in order to compose the mock HTTP response.
  4. If no mock can be matched, it will resolve the request with an error, unless real networking mode is enable, in which case a real HTTP request will be performed.

A piece of code to test

func PerformRequest() (*http.Response, error) {
	response, err := http.Get("http://myapi.com")
	if err != nil {
		return nil, err
	}
    
    return response, nil
}

Using Gock

func TestPerformRequest(t *testing.T) {
	// Arrange
	defer gock.Off()
    gock.New("http://myapi.com").
      Get("/").
      Reply(http.StatusOk)
    
    // Act
    response, err := PerformRequest()
    
    // Assert
    ...
}

Using Gock

gock.New("http://foo.com").
    MatchHeader("Authorization", "^foo bar$").
    HeaderPresent("Accept").
    MatchParam("page", "1").
    PathParam("user", "123").
    MatchType("json").
    JSON(map[string]string{"foo": "bar"}).
    Reply(http.StatusOK).
    SetHeader("Server", "gock").
    BodyString("foo foo").
    JSON(map[string]string{"foo": "bar"})

Code to test

type Information struct {
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
}

func GetInfos(userId int) (*Information, error) {
	response, err := http.Get(fmt.Sprintf("https://yourapitomock.io?user=%d", userId))
	if err != nil {
		return nil, err
	}

	if response.StatusCode != http.StatusOK {
		return nil, errors.New("invalid status code received")
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		return nil, err
	}

	info := &Information{}
	err = json.Unmarshal(body, info)
	if err != nil {
		return nil, err
	}
	return info, nil
}

Testing GetInfo

Using Gock, add unit test for GetInfo.

file/to/open.go
$ go test -run TestGetInfo

File to open:

Test your code:

unit/mocking/http_test.go

Testing GetInfo

func TestGetInfo(t *testing.T) {
	// Arrange
	defer gock.Off() // Flush pending mocks after test execution

	gock.New("https://yourapitomock.io").
		Get("/").
		MatchParam("user", "1").
		Reply(http.StatusOK).
		JSON(map[string]string{
			"first_name": "Parisa",
			"last_name":  "Tabriz",
		})

	// Act
	info, err := mocking.GetInfo(1)
    
    // Assert
    ...
}

Testing AddUser

Using Gock, add unit test for AddUser.

file/to/open.go
$ go test -run TestAddUser

File to open:

Test your code:

unit/mocking/http_test.go

Testing AddUser

func TestAddUser(t *testing.T) {
	// Arrange
	defer gock.Off() // Flush pending mocks after test execution

	gock.New("https://yourapitomock.io").
		Post("/user").
		MatchType("application/json").
		JSON(&mocking.Information{FirstName: "Grace", LastName:  "Hopper"}).
		Reply(http.StatusOK).
		JSON(map[string]int{"id": 42})

	// Act
	id, err := mocking.AddUser(&mocking.Information{FirstName: "Grace", LastName: "Hopper"})

	// Assert
	if err != nil {
		t.Fatalf("unexpected error: %s", err)
	}

	if id != 42 {
		t.Fatal("id is expected to be 42")
	}
}

Testing UpdateUser

Using Gock, add unit test for UpdateUser.

file/to/open.go
$ go test -run TestUpdateUser

File to open:

Test your code:

unit/mocking/http_test.go

Testing UpdateUser

func TestUpdateUser(t *testing.T) {
	// Arrange
	defer gock.Off() // Flush pending mocks after test execution

	gock.New("https://yourapitomock.io").
		Put("/user/1").
		MatchType("application/json").
		JSON(&mocking.Information{FirstName: "Grace", LastName: "Hopper"}).
		Reply(http.StatusNoContent)

	// Act
	err := mocking.UpdateUser(1, &mocking.Information{
		FirstName: "Grace",
		LastName:  "Hopper",
	})

	// Assert
	if err != nil {
		t.Fatalf("unexpected error: %s", err)
	}
}

Testing with Gock

We wrote our first test with Gock, on a nominal case. Remind that you should test nominal cases, but also borderline cases and error cases.

 

Attention: be careful while using Gock with parallel tests, and read the documentation to keep independent tests.

Mock SQL

When you have pieces of code performing requests to a database, you can create unit tests and mock the SQL database thanks to a tool.

 

We will use go-sqlmock to make this possible.

go-sqlmock

func TestDatabaseQuery(t *testing.T) {
	// Arrange
	db, mock, err := sqlmock.New() // db is a *sql.DB, mock is a sqlmock.SqlMock
    mock.
    	// ExpectXXX
    	ExpectQuery("SELECT ...").
        ExpectExec("UPDATE ...").
        
        // WithXXX
        WithArgs(...).
        WithoutArgs().
        
        // WillReturnXXX
        WillReturnError(errors.New("...")).
        WillDelayFor(5 * time.Second).
        WillReturnRows(
        	sqlmock.NewRows([]string{"id", "name"}).
            	AddRow(1, "Grace")
        )
    // Act
    user, err := GetUser(db, 1)
	// Assert    
}

go-sqlmock

// Testing db.Query("SELECT * FROM products WHERE id = $1", 42)

func TestFindProduct(t *testing.T) {
    // Arrange
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("error while getting mock: %s", err)
	}

	mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM products WHERE id = $1")).
		WithArgs(42).
		WillReturnRows(
			sqlmock.NewRows([]string{"id", "product_name", "price"}).
				AddRow(42, "Computer", 899.99),
		)
    
    // Act
    ...
    // Assert
    ...
}

Test ListUsers

Write unit test with SQL mock for ListUsers function.

file/to/open.go
$ go test -run TestListUsers

File to open:

Test your code:

unit/mocking/sql.go
unit/mocking/sql_test.go

TestListUsers

func TestListUsers(t *testing.T) {
	// Arrange
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("error while getting mock: %s", err)
	}
	databaseService := mocking.NewDatabaseService(db)

	mock.ExpectQuery("SELECT id, first_name, last_name FROM users").
		WillReturnRows(
			sqlmock.NewRows([]string{"id", "first_name", "last_name"}).
				AddRow(1, "Grace", "Hopper"),
		)
        
    // Act
    ...
    // Assert
    ...
}

Mocks

Mocks (HTTP or SQL) are often seen as the easy way to go to test some part of your code.

But usually, while mocking, you are in fact mocking some dependencies you don't own.

Integration tests are often a better way to go!

 

"Don't mock what you don't own"

https://hynek.me/articles/what-to-mock-in-5-mins/

Test FindUserByID

Write unit tests with SQL mock for FindUserByID function.

 

Write two tests:

  • Nominal case: TestFindUserByID
  • Error case: TestFindUserByIDNoRows
file/to/open.go
$ go test -run TestFindUserByID
$ go test -run TestFindUserByIDNoRows

File to open:

Test your code:

unit/mocking/sql.go
unit/mocking/sql_test.go

TestFindUserByID

...
	mock.ExpectQuery(regexp.QuoteMeta("SELECT id, first_name, last_name FROM users WHERE id = $1")).
	WithArgs(1).
	WillReturnRows(
		sqlmock.NewRows([]string{"id", "first_name", "last_name"}).
			AddRow(1, "Grace", "Hopper"),
	)
...

TestFindUserByIDNoRows

...
    mock.ExpectQuery(regexp.QuoteMeta("SELECT id, first_name, last_name FROM users WHERE id = $1")).
		WithArgs(1).
		WillReturnRows(sqlmock.NewRows([]string{"id", "first_name", "last_name"}))
...

Using fake data

Using fake data

Sometimes, you have to generate random data for your tests.

 

Faker is a package providing function to generate fake data: https://github.com/bxcodec/faker

 

Many data types are provided: https://pkg.go.dev/github.com/bxcodec/faker/v4#section-documentation

Testify

Testify is a toolkit to write tests in Go.

It comes with three main features:

  • Assert
  • Require
  • Suite

Testify assert


func TestSomething(t *testing.T) {
	assert.Equal(t, 123, 123, "they should be equal")

	assert.NotEqual(t, 123, 456, "they should not be equal")
  
	assert.Nil(t, object)
  
	assert.NoError(err)

  ...
}

Provide helpers to make assertion easier.

https://pkg.go.dev/github.com/stretchr/testify/assert

Testify require

func TestSomething(t *testing.T) {
	require.Equal(t, 123, 123, "they should be equal")

	require.NotEqual(t, 123, 456, "they should not be equal")
  
	require.Nil(t, object)
  
	require.NoError(err)

  ...
}

Same as assert, but with a FailNow.

https://pkg.go.dev/github.com/stretchr/testify/require

Testify suite

The suite package provides functionality that you might be used to from more common object-oriented languages.

With it, you can build a testing suite as a struct, build setup/teardown methods and testing methods on your struct, and run them with 'go test' as per normal. 

Testify suite

type TestSuite struct {
	suite.Suite
}

func (t *TestSuite) TestFunction1() {}

func (t *TestSuite) TestFunction2() {}

func (t *TestSuite) SetupSuite() {}

func (t *TestSuite) TearDownSuite() {}

func (t *TestSuite) SetupTest() {}

func (t *TestSuite) TearDownTest() {}

func TestMain(t *testing.T) {
	suite.Run(t, &TestSuite{})
}

Execution order:

  1. SetupSuite
  2. SetupTest
  3. TestFunction1
  4. TearDownTest
  5. SetupTest
  6. TestFunction2
  7. TearDownTest
  8. TearDownSuite

Rewrite SQL tests with Testify

file/to/open.go
$ go test

File to open:

Test your code:

unit/mocking/sql_testsuite_test.go

SQL tests with testify suite

type TestDatabaseServiceSuite struct {
	suite.Suite

	database     *sql.DB
	databaseMock sqlmock.Sqlmock
	service      *mocking.DatabaseService
}

func (t *TestDatabaseServiceSuite) SetupSuite() {
	var err error
	t.database, t.databaseMock, err = sqlmock.New()

	t.Require().NoError(err)

	t.service = mocking.NewDatabaseService(t.database)
}

func (t *TestDatabaseServiceSuite) TearDownSuite() {}

func (t *TestDatabaseServiceSuite) SetupTest() {}

func (t *TestDatabaseServiceSuite) TearDownTest() {
	t.Require().NoError(t.databaseMock.ExpectationsWereMet())
}

SQL tests with testify: TestListUsers

func (t *TestDatabaseServiceSuite) TestListUsers() {
	// Arrange
	t.databaseMock.ExpectQuery("SELECT id, first_name, last_name FROM users").
		WillReturnRows(
			sqlmock.NewRows([]string{"id", "first_name", "last_name"}).
				AddRow(1, "Grace", "Hopper"),
		)

	// Act
	users, err := t.service.ListUsers()

	// Assert
	t.Require().NoError(err)
	t.Require().Len(users, 1, "length should be one")
	t.Require().Equal(users[0], &mocking.User{ID: 1, FirstName: "Grace", LastName: "Hopper"}, "they should be equal")
}

SQL tests with testify: TestFindUserByID

func (t *TestDatabaseServiceSuite) TestFindUserByID() {
	t.databaseMock.ExpectQuery(regexp.QuoteMeta("SELECT id, first_name, last_name FROM users WHERE id = $1")).
		WithArgs(1).
		WillReturnRows(
			sqlmock.NewRows([]string{"id", "first_name", "last_name"}).
				AddRow(1, "Grace", "Hopper"),
		)

	// Act
	user, err := t.service.FindUserByID(1)

	// Assert
	t.Require().NoError(err)
	t.Require().NotNil(user)
	t.Require().Equal(user, &mocking.User{ID: 1, FirstName: "Grace", LastName: "Hopper"}, "they should be equal")
}

SQL tests with testify: TestFindUserByIDNoRows

func (t *TestDatabaseServiceSuite) TestFindUserByIDNoRows() {
	t.databaseMock.ExpectQuery(regexp.QuoteMeta("SELECT id, first_name, last_name FROM users WHERE id = $1")).
		WithArgs(1).
		WillReturnRows(sqlmock.NewRows([]string{"id", "first_name", "last_name"}))

	// Act
	user, err := t.service.FindUserByID(1)

	// Assert
	t.Require().ErrorIs(err, sql.ErrNoRows)
	t.Require().Nil(user)
}

SQL tests with testify: run your suite

func TestDatabaseService(t *testing.T) {
	suite.Run(t, &TestDatabaseServiceSuite{})
}

Testify

Testify will make your tests easier to write and read:

  • by aggregating the Arrange section thanks to the Setup
  • by aggregating part of the Assert section thanks to the TearDown
  • by making the Assert part easier to write

Integration testing

Integration tests

You might have noticed that mocking can be a solution for some use cases, but it does not fit all of them.

Especially when it comes to mock database. Spoiler: it becomes harder to test a database while using an ORM, for example. Or a SDK to a HTTP API.

 

This is why we should rely on integration tests to properly test the behaviour of an external service, by using or spawning the external service during tests. 

Build tags

Build Tags in Go is an identifier (in the form of a single line comment) added to the top level of your file, which tells the Go Compiler to what to do with the file during the build process. 

 

Build tags are used during the go build process, but also during the go test one!

 

Build tags can then be used to properly separate your test files.

Using build tags

// +build integration
package sql

func TestSqlQuery(t *testing.T) {
...
}

For example, you will be able to tag some files with a given tag, like integration. By default, go test command will ignore those files, unless you run go test -tags=integration.

$ go test -tags=integration

Using build tags

By doing so, you will be able to run integration tests only on the proper environment where your external dependencies are started.

 

Integration tests should run on your CI/CD, with a staging or preproduction environment.

 

But you can also use some tools to start your dependencies directly from your tests!

Testcontainers

Testcontainers

Testcontainers is an open source framework for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container.

https://testcontainers.com/

https://golang.testcontainers.org/

Testcontainers

func TestWithRedis(t *testing.T) {
    ctx := context.Background()
    req := testcontainers.ContainerRequest{
        Image:        "redis:latest",
        ExposedPorts: []string{"6379/tcp"},
        WaitingFor:   wait.ForLog("Ready to accept connections"),
    }
    redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        log.Fatalf("Could not start redis: %s", err)
    }
    defer func() {
        if err := redisC.Terminate(ctx); err != nil {
            log.Fatalf("Could not stop redis: %s", err)
        }
    }()
}

Testing Database Service with testcontainers

Let's rewrite our unit tests with testcontainers so it's based on a PostgreSQL container instead of SQL mocks.

File to open:

solution/integration/sql/testdata/
solution/integration/sql/sql_test.go

Testcontainers conclusion

Testcontainers can spawn any container from your code. You can write your own modules to make things easier, but it also comes with community modules you can use directly.

 

Be careful about keeping your tests independent!

Introduction to TDD

(bonus)

What's next?

Thanks! Questions?

Test your code

By Nathan Castelein

Test your code

  • 290