Concept
Integration tests exercise real components together rather than testing individual units in isolation. Where a unit test might mock a database call and verify that a function processes its return value correctly, an integration test runs the function against a real database and verifies the actual result.
Integration tests give you confidence that the pieces work together correctly: that SQL queries produce the expected results, that transactions behave as expected under concurrent access, and that HTTP handlers return the right responses end-to-end.
Problem
Unit tests with mocks can give false confidence. A unit test verifies that your code does the right thing with whatever the mock returns, but it doesn't verify that the real dependency returns what you expect, or that your SQL queries are syntactically and semantically correct.
For backend services that depend heavily on database correctness — like Simple Bank, where transaction semantics matter — unit tests with mocked databases are of limited value. They can't catch subtle query bugs, constraint violations, or incorrect isolation behaviour.
How it works
A practical approach for Go services with PostgreSQL:
- Run a real PostgreSQL instance in tests. Docker makes this straightforward: start a container at the beginning of the test suite, run migrations to get the schema, run tests, and stop the container.
- Use a test database separate from development and production. Migrations run fresh for each test run.
- Wrap each test in a transaction and roll it back at the end, rather than deleting test data. This keeps tests isolated without truncating tables.
Go's testing package supports this well. The TestMain function
runs setup and teardown for the entire test suite:
func TestMain(m *testing.M) {
// set up test database
db = setupTestDB()
exitCode := m.Run()
// tear down
db.Close()
os.Exit(exitCode)
}
For HTTP handler tests, Go's net/http/httptest package provides
a test server and recorder, allowing end-to-end handler tests without
starting a real server:
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/accounts", body)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
Trade-offs
Integration tests are slower than unit tests. They involve I/O — database connections, query round-trips, container startup. A suite of integration tests may take seconds or minutes rather than milliseconds.
They also require infrastructure. Running against a real database in CI requires the CI environment to have that database available. Docker-based test environments are the usual solution.
The balance I find useful: write integration tests for database interactions, HTTP handler behaviour, and anything that requires real components. Write unit tests for pure logic — validation rules, transformation functions, business calculations — where mocks are sufficient and tests run fast.
Practical application
In Simple Bank, all database operations are tested against a real PostgreSQL instance. The tests verify that concurrent transfers don't corrupt balances, that constraint violations are caught and returned correctly, and that transaction rollbacks work as expected.
The -race flag is always used when running tests for code that
uses goroutines. Go's race detector catches concurrent memory access bugs that
might not cause visible failures in regular test runs.
My understanding
For database-heavy services, integration tests are more valuable than unit
tests with mocked databases. A mock that returns sql.ErrNoRows
tells you your code handles that error correctly. A real database test tells
you the query is correct, the schema matches your assumptions, and the
transaction semantics are what you expect.
The investment in setting up a real test database pays off quickly. The confidence you get from tests that exercise actual SQL, actual constraints, and actual transaction behaviour is qualitatively different from tests against mocks.