Integration testing
Running end-to-end tests with mocked dependencies is a good thing to do, but we could improve confidence in our application even more if we also verify that our real MySQL database models are working as expected.
To do this we can run integration tests against a test version our MySQL database, which mimics our production database but exists for testing purposes only.
As a demonstration, in this chapter we’ll setup an integration test to ensure that our models.UserModel.Exists()
method is working correctly.
Test database setup and teardown
The first step is to create the test version of our MySQL database.
If you’re following along, connect to MySQL from your terminal window as the root
user and execute the following SQL statements to create a new test_snippetbox
database and test_web
user:
CREATE DATABASE test_snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'test_web'@'localhost'; GRANT CREATE, DROP, ALTER, INDEX, SELECT, INSERT, UPDATE, DELETE ON test_snippetbox.* TO 'test_web'@'localhost'; ALTER USER 'test_web'@'localhost' IDENTIFIED BY 'pass';
Once that’s done, let’s make two SQL scripts:
A setup script to create the database tables (so that they mimic our production database) and insert a known set of test data than we can work with in our tests.
A teardown script which drops the database tables and data.
The idea is that we’ll call these scripts at the start and end of each integration test, so that the test database is fully reset each time. This helps ensure that any changes we make during one test are not ‘leaking’ and affecting the results of another test.
Let’s go ahead and create these scripts in a new internal/models/testdata
directory like so:
$ mkdir internal/models/testdata $ touch internal/models/testdata/setup.sql $ touch internal/models/testdata/teardown.sql
CREATE TABLE snippets ( id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT NOT NULL, created DATETIME NOT NULL, expires DATETIME NOT NULL ); CREATE INDEX idx_snippets_created ON snippets(created); CREATE TABLE users ( id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, hashed_password CHAR(60) NOT NULL, created DATETIME NOT NULL ); ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email); INSERT INTO users (name, email, hashed_password, created) VALUES ( 'Alice Jones', 'alice@example.com', '$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG', '2022-01-01 09:18:24' );
DROP TABLE users; DROP TABLE snippets;
Alright, now that we’ve got the scripts in place, let’s make a new file to hold some helper functions for our integration tests:
$ touch internal/models/testutils_test.go
In this file let’s create a newTestDB()
helper function which:
- Creates a new
*sql.DB
connection pool for the test database; - Executes the
setup.sql
script to create the database tables and dummy data; - Register a ‘cleanup’ function which executes the
teardown.sql
script and closes the connection pool.
package models import ( "database/sql" "os" "testing" ) func newTestDB(t *testing.T) *sql.DB { // Establish a sql.DB connection pool for our test database. Because our // setup and teardown scripts contains multiple SQL statements, we need // to use the "multiStatements=true" parameter in our DSN. This instructs // our MySQL database driver to support executing multiple SQL statements // in one db.Exec() call. db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true") if err != nil { t.Fatal(err) } // Read the setup SQL script from the file and execute the statements, closing // the connection pool and calling t.Fatal() in the event of an error. script, err := os.ReadFile("./testdata/setup.sql") if err != nil { db.Close() t.Fatal(err) } _, err = db.Exec(string(script)) if err != nil { db.Close() t.Fatal(err) } // Use t.Cleanup() to register a function *which will automatically be // called by Go when the current test (or sub-test) which calls newTestDB() // has finished*. In this function we read and execute the teardown script, // and close the database connection pool. t.Cleanup(func() { defer db.Close() script, err := os.ReadFile("./testdata/teardown.sql") if err != nil { t.Fatal(err) } _, err = db.Exec(string(script)) if err != nil { t.Fatal(err) } }) // Return the database connection pool. return db }
The important thing to take away here is this:
Whenever we call this newTestDB()
function inside a test (or sub-test) it will run the setup script against the test database. And when the test or sub-test finishes, the cleanup function will automatically be executed and the teardown script will be run.
Testing the UserModel.Exists method
Now that the preparatory work is done, we’re ready to actually write our integration test for the models.UserModel.Exists()
method.
We know that our setup.sql
script creates a users
table containing one record (which should have the user ID 1
and email address alice@example.com
). So we want to test that:
- Calling
models.UserModel.Exists(1)
returns atrue
boolean value and anil
error value. - Calling
models.UserModel.Exists()
with any other user ID returns afalse
boolean value and anil
error value.
Let’s first head to our internal/assert
package and create a new NilError()
assertion, which we will use to check that an error value is nil
. Like so:
package assert ... func NilError(t *testing.T, actual error) { t.Helper() if actual != nil { t.Errorf("got: %v; expected: nil", actual) } }
Then let’s follow the Go conventions and create a new users_test.go
file for our test, directly alongside the code being tested:
$ touch internal/models/users_test.go
And add a TestUserModelExists
test containing the following code:
package models import ( "testing" "snippetbox.alexedwards.net/internal/assert" ) func TestUserModelExists(t *testing.T) { // Set up a suite of table-driven tests and expected results. tests := []struct { name string userID int want bool }{ { name: "Valid ID", userID: 1, want: true, }, { name: "Zero ID", userID: 0, want: false, }, { name: "Non-existent ID", userID: 2, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Call the newTestDB() helper function to get a connection pool to // our test database. Calling this here -- inside t.Run() -- means // that fresh database tables and data will be set up and torn down // for each sub-test. db := newTestDB(t) // Create a new instance of the UserModel. m := UserModel{db} // Call the UserModel.Exists() method and check that the return // value and error match the expected values for the sub-test. exists, err := m.Exists(tt.userID) assert.Equal(t, exists, tt.want) assert.NilError(t, err) }) } }
If you run this test, then everything should pass without any problems.
$ go test -v ./internal/models === RUN TestUserModelExists === RUN TestUserModelExists/Valid_ID === RUN TestUserModelExists/Zero_ID === RUN TestUserModelExists/Non-existent_ID --- PASS: TestUserModelExists (1.02s) --- PASS: TestUserModelExists/Valid_ID (0.33s) --- PASS: TestUserModelExists/Zero_ID (0.29s) --- PASS: TestUserModelExists/Non-existent_ID (0.40s) PASS ok snippetbox.alexedwards.net/internal/models 1.023s
The last line in the test output here is worth a mention. The total runtime for this test (1.023 seconds in my case) is much longer than for our previous tests — all of which took a few milliseconds to run. This big increase in runtime is primarily due to the large number of database operations that we needed to make during the tests.
While 1 second is a totally acceptable time to wait for this test in isolation, if you’re running hundreds of different integration tests against your database you might end up routinely waiting minutes — rather than seconds — for your tests to finish.
Skipping long-running tests
When your tests take a long time, you might decide that you want to skip specific long-running tests under certain circumstances. For example, you might decide to only run your integration tests before committing a change, instead of more frequently during development.
A common and idiomatic way to skip long-running tests is to use the testing.Short()
function to check for the presence of a -short
flag in your go test
command, and then call the t.Skip()
method to skip the test if the flag is present.
Let’s quickly update TestUserModelExists
to do this before running its actual tests, like so:
package models import ( "testing" "snippetbox.alexedwards.net/internal/assert" ) func TestUserModelExists(t *testing.T) { // Skip the test if the "-short" flag is provided when running the test. if testing.Short() { t.Skip("models: skipping integration test") } ... }
And then you can try running all the tests for the project with the -short
flag enabled. The output should look similar to this:
$ go test -v -short ./... === RUN TestPing --- PASS: TestPing (0.00s) === RUN TestSnippetView === RUN TestSnippetView/Valid_ID === RUN TestSnippetView/Non-existent_ID === RUN TestSnippetView/Negative_ID === RUN TestSnippetView/Decimal_ID === RUN TestSnippetView/String_ID === RUN TestSnippetView/Empty_ID --- PASS: TestSnippetView (0.01s) --- PASS: TestSnippetView/Valid_ID (0.00s) --- PASS: TestSnippetView/Non-existent_ID (0.00s) --- PASS: TestSnippetView/Negative_ID (0.00s) --- PASS: TestSnippetView/Decimal_ID (0.00s) --- PASS: TestSnippetView/String_ID (0.00s) --- PASS: TestSnippetView/Empty_ID (0.00s) === RUN TestUserSignup === RUN TestUserSignup/Valid_submission === RUN TestUserSignup/Invalid_CSRF_Token === RUN TestUserSignup/Empty_name === RUN TestUserSignup/Empty_email === RUN TestUserSignup/Empty_password === RUN TestUserSignup/Invalid_email === RUN TestUserSignup/Short_password === RUN TestUserSignup/Long_password === RUN TestUserSignup/Duplicate_email --- PASS: TestUserSignup (0.01s) --- PASS: TestUserSignup/Valid_submission (0.00s) --- PASS: TestUserSignup/Invalid_CSRF_Token (0.00s) --- PASS: TestUserSignup/Empty_name (0.00s) --- PASS: TestUserSignup/Empty_email (0.00s) --- PASS: TestUserSignup/Empty_password (0.00s) --- PASS: TestUserSignup/Invalid_email (0.00s) --- PASS: TestUserSignup/Short_password (0.00s) --- PASS: TestUserSignup/Long_password (0.00s) --- PASS: TestUserSignup/Duplicate_email (0.00s) === RUN TestCommonHeaders --- PASS: TestCommonHeaders (0.00s) === RUN TestHumanDate === RUN TestHumanDate/UTC === RUN TestHumanDate/Empty === RUN TestHumanDate/CET --- PASS: TestHumanDate (0.00s) --- PASS: TestHumanDate/UTC (0.00s) --- PASS: TestHumanDate/Empty (0.00s) --- PASS: TestHumanDate/CET (0.00s) PASS ok snippetbox.alexedwards.net/cmd/web 0.023s === RUN TestUserModelExists users_test.go:10: models: skipping integration test --- SKIP: TestUserModelExists (0.00s) PASS ok snippetbox.alexedwards.net/internal/models 0.003s ? snippetbox.alexedwards.net/internal/models/mocks [no test files] ? snippetbox.alexedwards.net/internal/validator [no test files] ? snippetbox.alexedwards.net/ui [no test files]
Notice the SKIP
annotation in the output above? This confirms that Go skipped our TestUserModelExists
test during this run.
If you like, feel free to run this again without the -short
flag, and you should see that the TestUserModelExists
test is executed as normal.