Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Working with Go's test cache on CI

Avatar for Björn Andersson Björn Andersson
August 21, 2025
1

Working with Go's test cache on CI

Avatar for Björn Andersson

Björn Andersson

August 21, 2025
Tweet

Transcript

  1. Working with Go's test cache on CI be fast by

    avoiding work, while doing the important work Björn Andersson @[email protected] @gaqzi.bsky.social ✉ [email protected]
  2. The Problem • Our CI for mikro (internal library) was

    slow • Over 5min per Go version, 10+ minutes total, nothing cached between runs • I needed mikro working with Go 1.24 • Only two versions supported at a time, 1.25 coming in August [July as of writing] • Found caching plug-in for Drone CI • Easy win: Cache GOPATH ✓ • Bigger win: Cache GOCACHE... cut CI time in half! • Pleasant surprise: tests started caching too 🥳 • Go compiler knows test dependencies, skips unchanged tests • Muthu, while reviewing, asked: "What about integration tests?" • Because they might get cached and they depend on code the compiler doesn't know about, 
 the cache will never get invalidated, so we can't cache them • …and how do I fi nd a way that has as few changes as possible? 
 So it can work across foodpanda
  3. The solution Three approaches 1. go clean -testcache • All

    tests will re-run, safe, no test speedup except for build cache 2. --count=1 • Common but manual approach, you'll see this online, not ideal for our situation (8+ places in mikro which has several go.mods) 3. Environment variables • Elegant, precise, can be little to no work, but requires thoughtful test design
  4. The solution The Environment Variable Approach func IsIntegrationTest(t *testing.T) {

    t.Helper() _ = os.Getenv("DRONE_COMMIT_SHA") // Cache invalidation } 

  5. The solution The Environment Variable Approach func IsIntegrationTest(t *testing.T) {

    t.Helper() _ = os.Getenv("DRONE_COMMIT_SHA") // Cache invalidation } func TestAPIIntegration(t *testing.T) { // Marks test as integration test integrationtesting.IsIntegrationTest(t) // Your actual test }
  6. The solution Why This Works • CI exposes unique commit

    SHA: DRONE_COMMIT_SHA, GITHUB_SHA, ... • Go tracks env vars used during tests, 
 different values = cache invalidation (same for fi les) • Reading the variable is enough - you don't need to use it • Package-level invalidation, if any test reads the variable,
 the entire package cache is invalidated • Keep integration tests in separate packages so unit tests stay cached
  7. The Investigation "But How Does It Actually Work?" • Go

    docs weren't clear about scope • I didn't trust that it would just work, software rarely do, 
 what's the scope? One test? Package? Sub-packages?
 Would reading env var invalidate all packages? • Found the testlog package - how Go tracks dependencies • Built an experiment to validate assumptions about scope and boundaries • Because I’d rather show it working, than be theoretical
  8. The Investigation The experiment • 4 test packages to test

    di ff erent scenarios • Main package with one read through os.Getenv,
 and tests in several fi les that don’t read • Direct read, I call os.Getenv in this package • External library read, through a func in another Go module • No env vars, to verify that caching isn't a ff ected • 9 scenarios tested, full experiment • What I was trying to learn: What are the boundaries?
  9. The Investigation Findings • ✅ Package-level invalidation (not test-level) •

    I already knew that Go runs packages in parallel, so this fi ts • ✅ Library calls count too • ✅ Package listing is either all (./...) or any list you give it • ❌ go test with no args = no cache • ❌ File-level targeting = no cache
  10. The Investigation Debugging • GODEBUG=gocachetest=1 go test ./... • See

    Go's caching decisions in real time • More options in go help cache
  11. The setup on CI Before steps: - name: validate-drone-environment image:

    golang:1.23 commands: - echo 1 - name: ci-cd image: golang:1.23 environment: ENVIRONMENT: test GOPRIVATE: github.com/deliveryhero NETRC: from_secret: NETRC commands: - make ci-cd-check depends_on: - validate-drone-environment
  12. The setup on CI After: setup environment: ENVIRONMENT: test GOCACHE:

    /drone/src/.go/go-build-cache GOPATH: /drone/src/.go GOPRIVATE: github.com/deliveryhero steps: - name: validate-drone-environment # … cut for space … - name: restore-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go restore: true Setup where to cache, the meltwater/drone-cache plugin will only work in your working dir
  13. The setup on CI After: setup environment: ENVIRONMENT: test GOCACHE:

    /drone/src/.go/go-build-cache GOPATH: /drone/src/.go GOPRIVATE: github.com/deliveryhero steps: - name: validate-drone-environment # … cut for space … - name: restore-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go restore: true Create a hash based on the go.mod and go.sum fi les, so when either changes then clear the cache and start over.
  14. The setup on CI After: setup environment: ENVIRONMENT: test GOCACHE:

    /drone/src/.go/go-build-cache GOPATH: /drone/src/.go GOPRIVATE: github.com/deliveryhero steps: - name: validate-drone-environment # … cut for space … - name: restore-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go restore: true The .go folder in /drone/src
  15. steps: # ... - name: ci-cd # … cut for

    space … - name: save-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go override: false rebuild: true depends_on: - ci-cd The setup on CI After: post-test The same cache key, important!
  16. steps: # ... - name: ci-cd # … cut for

    space … - name: save-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go override: false rebuild: true depends_on: - ci-cd The setup on CI After: post-test The same folder
  17. steps: # ... - name: ci-cd # … cut for

    space … - name: save-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go override: false rebuild: true depends_on: - ci-cd The setup on CI After: post-test Don’t override if the cache already exists
  18. steps: # ... - name: ci-cd # … cut for

    space … - name: save-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go override: false rebuild: true depends_on: - ci-cd The setup on CI After: post-test And build the cache and upload it
  19. steps: # ... - name: ci-cd # … cut for

    space … - name: save-cache image: meltwater/drone-cache pull: true settings: archive_format: gzip cache_key: go-{{ hashFiles "go.mod" "go.sum" }} mount: - .go override: false rebuild: true depends_on: - ci-cd The setup on CI After: post-test And only do this after the ci-cd step has fi nished green
  20. The Results • Cut CI times in half • For

    mikro speci fi cally: I chose the simple go clean -testcache approach • Fast enough already, didn't know tests well enough to optimize further • Go's philosophy: Simple, predictable solutions that just work • No complexity - they built testing concerns into the language itself • Experiment available: github.com/gaqzi/experiment-go-test-caching • sanitarium.se/blog/2025/07/07/working-with-gos-test-cache-on-ci/