When the Migration Table Lies in Bidang Integration Tests
Green suite, stale schema: schema_migrations only logs executed files, a leftover Docker volume quietly carries the old schema.
TL;DR
Tests stayed green after adding the bidang column because a stale MySQL volume kept the old schema while the migration version looked current. With no new files to run, the migrator did nothing and left the mismatch invisible. Now volumes are wiped before integration runs so migrations rebuild a fresh schema and six bidang scope scenarios are reliably verified.
All tests were green, but I could not shake the suspicion. I had just added a bidang column to the announcements table, and while running the integration suite against a real MySQL 5.7, one stray question showed up: does the test database actually have that column?
I opened the schema_migrations table. Neatly written there: version 58. My first guess: if the version is the latest, the schema must be new too. Plus, no test was red. Fine, moving on.
Both turned out wrong. The version table only records which migration files have been executed, not the actual contents of the volume where that database lives. Two signals that both feel strong can lie at the same time.
Leftover volume from yesterday's session
The root cause was in the environment, not the code. The docker-compose.test.yml file for MySQL 5.7 is disposable, but its volume is named and it sticks around between runs. The old schema, from before the bidang column existed, was still intact in there. golang-migrate, which applies migrations in version order per its official document [3], saw schema_migrations at 58, found no new files to run, and went home quietly.
So that version number feels very convincing, while it only speaks about files that were executed. The schema on the volume can go stale without anyone noticing. False positives like this are the most dangerous kind: no alarm goes off, and the results are all green.
Fixing it from the environment hygiene side
The fix is not in the Go code. The teardown became docker compose down --volumes, which per the official Compose documentation removes named volumes declared in the Compose file plus anonymous volumes attached to containers on its command page [2]. The volume is guaranteed to be recreated from zero, testutil.SetupTestDB runs the migrations from the start, and the bidang column truly exists before the first test executes.
The mechanism slipped through precisely because nothing looked broken. If a new migration file had told the database to ALTER the table, MySQL would have protested loudly the moment the schemas clashed. The dangerous scenario is this calm one: an old volume, no new files to run, the unit suite staying green, and the only trace that something is off is the voice in my own head. Had I trusted the integration suite, its results would have been unusable in an argument: the test that was supposed to cover the bidang column never touched that column, because the column was never in the database it ran against.
The scope matrix finally locked
Once the environment was clean, the real value of this test file showed up. Six bidang scope scenarios are now checked against a real database, not mocks: a listing with the olahraga scope only sees global content plus its own, the ALL scope sees all three, a cross-bidang update hits ErrForbidden which becomes 403 at the API, a scoped user is denied when editing global content, and DeletePermanent by another bidang is denied too. The tests are wrapped in the integration build tag, so they only run when explicitly invoked and do not crowd the daily unit runs.
//go:build integration
func TestAnnouncementBidangScopeCRUD(t *testing.T) {
db := testutil.SetupTestDB(t)
repo := announcementadmin.NewMySQLRepository(db)
svc := announcementadmin.NewService(repo, nil)
// olahraga scope listing: global (NULL) + olahraga only
items, total, err := repo.List(ctx, announcementadmin.ListQuery{
Status: "published", Bidang: scope.Olahraga,
})
if total != 2 {
t.Fatalf("total = %d, want 2", total)
}
}
The price of this discipline is clear: every integration round pays for a full MySQL boot plus all migrations from zero. That is why these tests are wrapped in the integration build tag and listed in the scripts/unit_testing index, so they only run when called, not on every commit. The six-scenario matrix does not need to run every minute; it runs whenever something on the bidang scope path changes. And when that happens, certainty about the schema is not a bonus, it is the requirement for the results to be trustworthy.
Along the way, two ListCategories call sites that still used the old signature were updated to the module-filtered form. The quietly stale test got refreshed in the same commit. One standing decision from this cycle: the version table is no longer treated as the single source of truth about the schema. The test environment must be destroyed completely before it is used, so that green really means green.