Chapter 19: Temp Files and Parsing¶
Description¶
Use t.TempDir() + os.WriteFile to create on-disk fixture files for functions that read and parse them. The example in this chapter is a Go source parser: tests write an entity constructor file into a temp directory, and production code parses it with go/parser / go/ast to extract the constructor's parameters. Each case writes its own fixture, and t.TempDir() cleans it up automatically — no shared fixtures, no collisions, no leftovers.
Code¶
type Field struct {
Name string
Type string
}
type entityCtorInfo struct {
newArgs string // pre-joined argument list for New<entity>(...)
createFields []Field // fields for Create<entity>Input (the non-auto params)
needsTime bool // generated code references time.*
needsUUID bool // generated code references uuid.*
}
func entityConstructorInfo(entityFile, entityName string) (*entityCtorInfo, error) {
f, err := parser.ParseFile(token.NewFileSet(), entityFile, nil, 0)
if err != nil {
return nil, fmt.Errorf("parsing entity file %s: %w", entityFile, err)
}
constructorName := "New" + entityName
params, found := extractConstructorParams(f, constructorName)
if !found {
return nil, fmt.Errorf("constructor %s not found in %s", constructorName, entityFile)
}
info := &entityCtorInfo{}
for _, p := range params {
switch {
case isIDParam(p.name) && (p.typ == "string" || p.typ == "uuid.UUID"):
if p.typ == "uuid.UUID" {
info.newArgsOrAppend("uuid.New()")
} else {
info.newArgsOrAppend("uuid.NewString()")
}
info.needsUUID = true
case p.typ == "time.Time":
info.newArgsOrAppend("time.Now()")
info.needsTime = true
default:
fieldName := toTitleCase(p.name)
info.createFields = append(info.createFields, Field{Name: fieldName, Type: p.typ})
info.newArgsOrAppend("input." + fieldName)
if strings.Contains(p.typ, "time.") {
info.needsTime = true
}
if strings.Contains(p.typ, "uuid.") {
info.needsUUID = true
}
}
}
return info, nil
}
type namedParam struct {
name string
typ string
}
func extractConstructorParams(f *ast.File, funcName string) ([]namedParam, bool) {
for _, decl := range f.Decls {
fd, ok := decl.(*ast.FuncDecl)
if !ok || fd.Name.Name != funcName || fd.Type.Params == nil {
continue
}
var params []namedParam
for _, pf := range fd.Type.Params.List {
typ := types.ExprString(pf.Type)
names := pf.Names
if len(names) == 0 {
names = []*ast.Ident{{Name: "_"}}
}
for _, n := range names {
params = append(params, namedParam{name: n.Name, typ: typ})
}
}
return params, true
}
return nil, false
}
func isIDParam(name string) bool {
return strings.EqualFold(name, "id") || strings.EqualFold(name, "uuid")
}
func (c *entityCtorInfo) newArgsOrAppend(arg string) {
if c.newArgs == "" {
c.newArgs = arg
} else {
c.newArgs += ", " + arg
}
}
func toTitleCase(s string) string {
if len(s) == 0 {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
Test¶
type entityConstructorInfoFn func(*testing.T, *entityCtorInfo, error)
var checkentityConstructorInfo = func(fns ...entityConstructorInfoFn) []entityConstructorInfoFn { return fns }
func checkentityConstructorInfoError(want string) entityConstructorInfoFn {
return func(t *testing.T, _ *entityCtorInfo, err error) {
t.Helper()
if want == "" {
assert.NoErrorf(t, err, "checkentityConstructorInfoError: expected no error, got %v", err)
return
}
if assert.Errorf(t, err, "checkentityConstructorInfoError: expected error %q", want) {
assert.Containsf(t, err.Error(), want, "checkentityConstructorInfoError mismatch")
}
}
}
func checkNewArgs(want string) entityConstructorInfoFn {
return func(t *testing.T, info *entityCtorInfo, _ error) {
t.Helper()
assert.Equal(t, want, info.newArgs)
}
}
func checkCreateFields(want []Field) entityConstructorInfoFn {
return func(t *testing.T, info *entityCtorInfo, _ error) {
t.Helper()
assert.Equal(t, want, info.createFields)
}
}
func checkNeedsUUID(want bool) entityConstructorInfoFn {
return func(t *testing.T, info *entityCtorInfo, _ error) {
t.Helper()
assert.Equal(t, want, info.needsUUID)
}
}
func checkNeedsTime(want bool) entityConstructorInfoFn {
return func(t *testing.T, info *entityCtorInfo, _ error) {
t.Helper()
assert.Equal(t, want, info.needsTime)
}
}
func writeEntityFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "entity.go")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func Test_entityConstructorInfo(t *testing.T) {
tests := []struct {
name string
entityName string
content string
checks []entityConstructorInfoFn
}{
{
name: "string id plus extra field",
entityName: "Category",
content: "package categories\nfunc NewCategory(id string, description string) (*Category, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs(`uuid.NewString(), input.Description`),
checkCreateFields([]Field{{Name: "Description", Type: "string"}}),
checkNeedsUUID(true),
),
},
{
name: "uuid id and timestamp",
entityName: "User",
content: "package users\nfunc NewUser(id uuid.UUID, createdAt time.Time) (*User, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs("uuid.New(), time.Now()"),
checkNeedsUUID(true),
checkNeedsTime(true),
),
},
{
name: "id only",
entityName: "Note",
content: "package notes\nfunc NewNote(id string) (*Note, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs("uuid.NewString()"),
checkNeedsUUID(true),
),
},
{
name: "no id field",
entityName: "Greeting",
content: "package greetings\nfunc NewGreeting(name string, email string) (*Greeting, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs("input.Name, input.Email"),
checkCreateFields([]Field{{Name: "Name", Type: "string"}, {Name: "Email", Type: "string"}}),
),
},
{
name: "grouped param names",
entityName: "Pair",
content: "package pairs\nfunc NewPair(a, b string) (*Pair, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs("input.A, input.B"),
checkCreateFields([]Field{{Name: "A", Type: "string"}, {Name: "B", Type: "string"}}),
),
},
{
name: "pointer and slice fields become input fields",
entityName: "Order",
content: "package orders\nfunc NewOrder(id string, total float64, items []string, at *time.Time) (*Order, error) { return nil, nil }\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError(""),
checkNewArgs("uuid.NewString(), input.Total, input.Items, input.At"),
checkCreateFields([]Field{
{Name: "Total", Type: "float64"},
{Name: "Items", Type: "[]string"},
{Name: "At", Type: "*time.Time"},
}),
checkNeedsUUID(true),
checkNeedsTime(true),
),
},
{
name: "constructor missing",
entityName: "Category",
content: "package categories\n",
checks: checkentityConstructorInfo(
checkentityConstructorInfoError("constructor NewCategory not found"),
),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
path := writeEntityFile(t, tt.content)
r, err := entityConstructorInfo(path, tt.entityName)
for _, c := range tt.checks {
c(t, r, err)
}
})
}
}
func Test_entityConstructorInfo_missingFile(t *testing.T) {
_, err := entityConstructorInfo(filepath.Join(t.TempDir(), "nope.go"), "Category")
assert.Error(t, err)
}
Scaffold¶
Generate test scaffolding with go-testgen:
Testing Approach¶
Temp files and parsing:
- Per-case fixtures with
t.TempDir()— thewriteEntityFilehelper writes each case'sentity.gointo a fresh temp directory that Go removes automatically. No shared fixtures, noos.RemoveAll, no leftover files between runs. - Write, then parse — the fixture content is raw source text (a constructor signature), written to a real file on disk.
entityConstructorInfoexercises the fullgo/parser+go/astpath against an actual file, including the file-not-found error case. - Signature-driven table cases — the seven rows cover every decision branch of the parser: string id →
uuid.NewString(),uuid.UUIDid →uuid.New(),time.Time→time.Now(), grouped parameter names (a, b string), pointer and slice types, and a file with no constructor at all. - Distinct error paths — a missing file (parse failure) and a missing constructor (AST lookup failure) are separate cases with separate checks, so a regression in either branch is reported at the exact line.
View source code on GitHub