| name | testing-gorm-many-to-many-custom-timestamps |
| description | How to test GORM many-to-many relationships with join tables that have custom timestamp fields (beyond CreatedAt/UpdatedAt) |
| domain | testing |
| confidence | high |
| source | earned |
Context
GORM's automatic association handling does not populate custom timestamp fields in join tables during association replacement. When a many-to-many join table has fields like AddedAt (for CoinSetMembership) marked as NOT NULL, naive GORM updates will violate the constraint.
This skill covers:
- Testing that Update operations preserve existing join table rows with custom timestamps
- Verifying that
Omit() correctly prevents association replacement
- Setting up test databases to include join table models
Patterns
1. Include Join Table Models in Test Setup
func setupTestDB(t *testing.T) *gorm.DB {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
db.AutoMigrate(
&models.Coin{}, &models.CoinSet{},
&models.CoinSetMembership{},
)
return db
}
2. Test Pattern: Verify Timestamp Preservation
func TestUpdate_PreservesCustomTimestamps(t *testing.T) {
setRepo.AddCoinToSet(coinID, setID, userID, notes)
var membership models.CoinSetMembership
db.Where("coin_id = ? AND set_id = ?", coinID, setID).First(&membership)
originalAddedAt := membership.AddedAt
coinRepo.Update(coin, &models.Coin{Name: "Updated"})
db.Where("coin_id = ? AND set_id = ?", coinID, setID).First(&membership)
if !membership.AddedAt.Equal(originalAddedAt) {
t.Error("AddedAt changed; Omit() failed")
}
}
3. Test Pattern: Verify Omit() Effectiveness
func TestUpdate_IgnoresAssociationField(t *testing.T) {
setRepo.AddCoinToSet(coinID, set1.ID, userID, "")
updates := &models.Coin{
Name: "Updated",
Sets: []CoinSet{*set2},
}
coinRepo.Update(coin, updates)
var count int64
db.Model(&models.CoinSetMembership{}).Where("coin_id = ?", coinID).Count(&count)
if count != 1 {
t.Error("membership count changed; Omit() failed")
}
var membership models.CoinSetMembership
if err := db.Where("coin_id = ? AND set_id = ?", coinID, set1.ID).First(&membership).Error; err != nil {
t.Error("original membership disappeared")
}
}
Examples
See src/api/repository/coin_repository_test.go:
TestCoinRepository_Update_PreservesSets (lines 333-374)
TestCoinRepository_Update_WithSetsField (lines 376-430)
Join table model:
CoinSetMembership (models/set.go:38-43) — has AddedAt field
Repository implementations:
CoinRepository.Update (coin_repository.go:337-344) — uses Omit("Tags", "Sets")
SetRepository.AddCoinToSet (set_repository.go:82-108) — populates AddedAt via time.Now()
Anti-Patterns
❌ Missing Join Table in Test Setup
db.AutoMigrate(&models.Coin{}, &models.CoinSet{})
❌ Not Capturing Original Timestamp
coinRepo.Update(coin, updates)
var membership models.CoinSetMembership
db.First(&membership)
if membership.AddedAt.IsZero() {
t.Error("timestamp zero")
}
❌ Naive Association Replacement in Production Code
coin.Sets = []CoinSet{newSet}
db.Model(&coin).Updates(coin)
db.Model(&coin).Omit("Tags", "Sets").Updates(coin)
❌ Testing Only Happy Path
if count := countMemberships(coinID); count == 0 {
t.Error("membership missing")
}
if membership.AddedAt.IsZero() {
t.Error("AddedAt zero")
}
if !membership.AddedAt.Equal(originalAddedAt) {
t.Error("AddedAt changed")
}
Notes
- This pattern applies to any join table with custom fields requiring manual population
- If GORM adds auto-population support for custom join fields in future versions, these tests will prove migration safety
- Regression tests must verify BOTH non-zero timestamps AND preservation across updates