seams
Go static analyzer that reports untestable function/method calls.
As a command
Add seams as a tool dependency in your go.mod:
$ go get -tool github.com/ichiban/seams/cmd/seams
Then, run via go tool:
$ go tool seams ./...
main.go:8:17: untestable function/method call: time.Parse
main.go:11:7: untestable function/method call: (time.Duration).Hours
main.go:11:7: untestable function/method call: (time.Time).Sub
main.go:11:16: untestable function/method call: time.Now
main.go:12:2: untestable function/method call: fmt.Printf
To automatically fix the issues by introducing seam variables:
$ go tool seams -fix ./...
As an analysis.Analyzer
Add the package as a dependency:
$ go get github.com/ichiban/seams
Then, include seams.Analyzer in your checker.
package main
import (
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/multichecker"
"golang.org/x/tools/go/analysis/passes/nilfunc"
"golang.org/x/tools/go/analysis/passes/printf"
"golang.org/x/tools/go/analysis/passes/shift"
"github.com/ichiban/seams"
)
func main() {
multichecker.Main(
// other analyzers of your choice
nilfunc.Analyzer,
printf.Analyzer,
shift.Analyzer,
seams.Analyzer,
)
}
Automatic fixing with -fix
The -fix flag automatically transforms untestable calls into testable ones by introducing seam variables.
Function calls
Function calls are transformed by creating a package-level variable and updating the call site:
// Before
fmt.Printf("Hello")
// After
var printf = fmt.Printf
printf("Hello")
Method calls
Method calls are transformed using method expressions. The receiver becomes the first argument:
// Before
var b strings.Builder
b.WriteString("text")
// After
var stringsBuilderWriteString = (*strings.Builder).WriteString
stringsBuilderWriteString(&b, "text")
Naming conventions
| Call type |
Naming rule |
Example |
| Function |
Lowercase first letter |
fmt.Printf → printf |
| Method |
Package + Type + Method |
(*strings.Builder).WriteString → stringsBuilderWriteString |
If a variable with the generated name already exists, the fix will reuse it without adding a new declaration.
What do you mean by untestable?
This static analyzer reports function/method calls that are of:
- functions/methods defined in other packages,
- not builtin functions,
- not in tests, nor
- in generated files
because those function/method calls can't be replaced by test doubles in tests.
For example, time.Now() isn't testable because we can't change its behaviour for our tests.
Though, timeNow() where var timeNow = time.Now is testable because we can change the behaviour in tests.
untestable example
package main
import (
"fmt"
"time"
)
var date = must(time.Parse(time.RFC3339, "2019-12-20T00:00:00+09:00"))
func main() {
d := date.Sub(time.Now()).Hours() / 24
fmt.Printf("%d days until Star Wars: The Rise of Skywalker\n", int(d))
}
func must(t time.Time, err error) time.Time {
if err != nil {
panic(err)
}
return t
}
testable example
package main
import (
"fmt"
"time"
)
var (
timeParse = time.Parse
timeDurationHours = time.Duration.Hours
timeTimeSub = time.Time.Sub
timeNow = time.Now
fmtPrintf = fmt.Printf
)
var date = must(timeParse(time.RFC3339, "2019-12-20T00:00:00+09:00"))
func main() {
d := timeDurationHours(timeTimeSub(date, timeNow())) / 24
fmtPrintf("%d days until Star Wars: The Rise of Skywalker\n", int(d))
}
func must(t time.Time, err error) time.Time {
if err != nil {
panic(err)
}
return t
}
package main
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func Test_main(t *testing.T) {
t.Run("on the day", func(t *testing.T) {
assert := assert.New(t)
now, err := time.Parse(time.RFC3339, "2019-12-20T12:00:00+09:00")
assert.NoError(err)
n := timeNow
defer func() { timeNow = n }()
timeNow = func() time.Time {
return now
}
called := false
p := fmtPrintf
defer func() { fmtPrintf = p }()
fmtPrintf = func(format string, a ...interface{}) (int, error) {
assert.Equal("%d days until Star Wars: The Rise of Skywalker\n", format)
assert.Equal([]interface{}{0}, a)
called = true
return 0, nil
}
main()
assert.True(called)
})
}
License
This project is licensed under the MIT License - see the LICENSE.md file for details