SHODO
Morning session
Afternoon session
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
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.
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, ...
An integration test determines if some or all application components, after being unit tested, still operate properly while working together.
End to end tests check that all components, from the UI to the external dependencies (databases, external REST APIs, etc.) are working together.
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.
In this training session, we will go through:
Unit tests should follow the F.I.R.S.T. approach:
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.
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.
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.
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.
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.
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
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.
| Test | Write tests for your code (unit tests, integration tests) |
| Fuzzing | Write fuzzing tests for your code |
| Benchmark | Write benchmarks for your code |
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 provides functions to:
Log
Fail
Log & fail
Skip
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.")
}
...
}$ 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!
Write 4 unit tests to test the behaviour of the FizzBuzz function:
file/to/open.go$ go test .File to open:
Test your code:
unit/fizzbuzz/fizzbuzz_test.gofunc 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)
}
}While writing tests, it becomes quite a standard to divide your test method into three sections:
| 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. |
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)
}
}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 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
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)
}
}
}Rewrite the 4 unit tests to a single test, using Table-Driven Tests pattern.
file/to/open.go$ go test -run TestFizzBuzzWithTableDrivenTestFile to open:
Test your code:
unit/fizzbuzz/fizzbuzz_tdt_test.gofunc 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",
},
}
...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)
}
}
}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)
}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.
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)
}
}The Parallel() function can be used inside a test to declare parallelism.
How to use parallelism with table-driven 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
})
}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.
Add sub-tests in the Table-Driven tests with t.Run and t.Parallel function.
file/to/open.go$ go test -run TestFizzBuzzWithTableDrivenTestAndParallelismFile to open:
Test your code:
unit/fizzbuzz/fizzbuzz_tdt_run_test.gofunc 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)
}
})
}
}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
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.
$ 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.
Visual Studio Code can be configured to show coverage in the IDE.
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 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
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.
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)
}
})
}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.
How can we use fuzzing to detect issues on FizzBuzz?
Write the FuzzFizzbuzz unit test with fuzzing to check that:
file/to/open.go$ go test -run=Fuzz -fuzz=Fuzz -fuzztime=5sFile to open:
Test your code:
unit/fizzbuzz/fizzbuzz_fuzzing_test.gofunc 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)
}
}
})
}$ 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.048sIn 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:
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".
Let's now have a look on how to use mocks to test code with dependancies to:
Go provides a standard package for http unit tests: httptest
This library provides tools to:
func HelloWorldHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello world!")
}We want to test this handler:
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))
}
}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:
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)
}
}func PerformRequest() (*http.Response, error) {
response, err := http.Get("http://myapi.com")
if err != nil {
return nil, err
}
return response, nil
}func TestPerformRequest(t *testing.T) {
// Arrange
defer gock.Off()
gock.New("http://myapi.com").
Get("/").
Reply(http.StatusOk)
// Act
response, err := PerformRequest()
// Assert
...
}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"})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
}Using Gock, add unit test for GetInfo.
file/to/open.go$ go test -run TestGetInfoFile to open:
Test your code:
unit/mocking/http_test.gofunc 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
...
}Using Gock, add unit test for AddUser.
file/to/open.go$ go test -run TestAddUserFile to open:
Test your code:
unit/mocking/http_test.gofunc 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")
}
}Using Gock, add unit test for UpdateUser.
file/to/open.go$ go test -run TestUpdateUserFile to open:
Test your code:
unit/mocking/http_test.gofunc 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)
}
}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.
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.
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
}// 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
...
}Write unit test with SQL mock for ListUsers function.
file/to/open.go$ go test -run TestListUsersFile to open:
Test your code:
unit/mocking/sql.go
unit/mocking/sql_test.gofunc 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 (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"
Write unit tests with SQL mock for FindUserByID function.
Write two tests:
file/to/open.go$ go test -run TestFindUserByID
$ go test -run TestFindUserByIDNoRowsFile to open:
Test your code:
unit/mocking/sql.go
unit/mocking/sql_test.go...
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"),
)
......
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"}))
...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 is a toolkit to write tests in Go.
It comes with three main features:
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.
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.
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.
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:
Let's rewrite SQL tests with Testify.
Documentation: https://pkg.go.dev/github.com/stretchr/testify/suite
Example: https://github.com/stretchr/testify?tab=readme-ov-file#suite-package
file/to/open.go$ go testFile to open:
Test your code:
unit/mocking/sql_testsuite_test.gotype 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())
}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")
}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")
}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)
}func TestDatabaseService(t *testing.T) {
suite.Run(t, &TestDatabaseServiceSuite{})
}Testify will make your tests easier to write and read:
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 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.
// +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=integrationBy 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 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.
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)
}
}()
}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.goTestcontainers 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!
(bonus)