diff --git a/CHANGELOG.md b/CHANGELOG.md index b0127d9..869c119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This document is formatted according to the principles of [Keep A CHANGELOG](htt ## Unreleased ### Added +- Support for reading feature files from an `fs.FS` ([550](https://github.com/cucumber/godog/pull/550) - [tigh-latte](https://github.com/tigh-latte)) - Added keyword functions. ([509](https://github.com/cucumber/godog/pull/509) - [otrava7](https://github.com/otrava7)) - prefer go test to use of godog cli in README ([548](https://github.com/cucumber/godog/pull/548) - [danielhelfand](https://github.com/danielhelfand) diff --git a/README.md b/README.md index f1c023c..494f442 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,32 @@ func (a *asserter) Errorf(format string, args ...interface{}) { } ``` +### Embeds + +If you're looking to compile your test binary in advance of running, you can compile the feature files into the binary via `go:embed`: + +```go + +//go:embed features/* +var features embed.FS + +var opts = godog.Options{ + Paths: []string{"features"}, + FS: features, +} +``` + +Now, the test binary can be compiled with all feature files embedded, and can be ran independently from the feature files: + +```sh +> go test -c ./test/integration/integration_test.go +> mv integration.test /some/random/dir +> cd /some/random/dir +> ./integration.test +``` + +**NOTE:** `godog.Options.FS` is as `fs.FS`, so custom filesystem loaders can be used. + ## CLI Mode **NOTE:** The [`godog` CLI has been deprecated](https://github.com/cucumber/godog/discussions/478). It is recommended to use `go test` instead. diff --git a/go.sum b/go.sum index 6b6cdd6..2c7e39f 100644 --- a/go.sum +++ b/go.sum @@ -46,7 +46,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= diff --git a/internal/flags/options.go b/internal/flags/options.go index f908467..fed059a 100644 --- a/internal/flags/options.go +++ b/internal/flags/options.go @@ -3,6 +3,7 @@ package flags import ( "context" "io" + "io/fs" "testing" ) @@ -70,6 +71,10 @@ type Options struct { // in a map entry FeatureContents []Feature + // FS allows passing in an `fs.FS` to read features from, such as an `embed.FS` + // or os.DirFS(string). + FS fs.FS + // ShowHelp enables suite to show CLI flags usage help and exit. ShowHelp bool } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index db5bb2d..3db1b52 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -4,14 +4,14 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" - "path/filepath" "regexp" "strconv" "strings" - "github.com/cucumber/gherkin/go/v26" - "github.com/cucumber/messages/go/v21" + gherkin "github.com/cucumber/gherkin/go/v26" + messages "github.com/cucumber/messages/go/v21" "github.com/cucumber/godog/internal/flags" "github.com/cucumber/godog/internal/models" @@ -33,8 +33,8 @@ func ExtractFeaturePathLine(p string) (string, int) { return retPath, line } -func parseFeatureFile(path string, newIDFunc func() string) (*models.Feature, error) { - reader, err := os.Open(path) +func parseFeatureFile(fsys fs.FS, path string, newIDFunc func() string) (*models.Feature, error) { + reader, err := fsys.Open(path) if err != nil { return nil, err } @@ -70,9 +70,9 @@ func parseBytes(path string, feature []byte, newIDFunc func() string) (*models.F return &f, nil } -func parseFeatureDir(dir string, newIDFunc func() string) ([]*models.Feature, error) { +func parseFeatureDir(fsys fs.FS, dir string, newIDFunc func() string) ([]*models.Feature, error) { var features []*models.Feature - return features, filepath.Walk(dir, func(p string, f os.FileInfo, err error) error { + return features, fs.WalkDir(fsys, dir, func(p string, f fs.DirEntry, err error) error { if err != nil { return err } @@ -85,7 +85,7 @@ func parseFeatureDir(dir string, newIDFunc func() string) ([]*models.Feature, er return nil } - feat, err := parseFeatureFile(p, newIDFunc) + feat, err := parseFeatureFile(fsys, p, newIDFunc) if err != nil { return err } @@ -95,21 +95,29 @@ func parseFeatureDir(dir string, newIDFunc func() string) ([]*models.Feature, er }) } -func parsePath(path string, newIDFunc func() string) ([]*models.Feature, error) { +func parsePath(fsys fs.FS, path string, newIDFunc func() string) ([]*models.Feature, error) { var features []*models.Feature path, line := ExtractFeaturePathLine(path) - fi, err := os.Stat(path) + fi, err := func() (fs.FileInfo, error) { + file, err := fsys.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + return file.Stat() + }() if err != nil { return features, err } if fi.IsDir() { - return parseFeatureDir(path, newIDFunc) + return parseFeatureDir(fsys, path, newIDFunc) } - ft, err := parseFeatureFile(path, newIDFunc) + ft, err := parseFeatureFile(fsys, path, newIDFunc) if err != nil { return features, err } @@ -138,14 +146,14 @@ func parsePath(path string, newIDFunc func() string) ([]*models.Feature, error) } // ParseFeatures ... -func ParseFeatures(filter string, paths []string) ([]*models.Feature, error) { +func ParseFeatures(fsys fs.FS, filter string, paths []string) ([]*models.Feature, error) { var order int featureIdxs := make(map[string]int) uniqueFeatureURI := make(map[string]*models.Feature) newIDFunc := (&messages.Incrementing{}).NewId for _, path := range paths { - feats, err := parsePath(path, newIDFunc) + feats, err := parsePath(fsys, path, newIDFunc) switch { case os.IsNotExist(err): diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index f03dfbf..222f317 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -1,10 +1,11 @@ package parser_test import ( - "io/ioutil" - "os" + "errors" + "io/fs" "path/filepath" "testing" + "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -71,16 +72,15 @@ Feature: eat godogs When I eat 5 Then there should be 7 remaining` - baseDir := filepath.Join(os.TempDir(), t.Name(), "godogs") - errA := os.MkdirAll(baseDir+"/a", 0755) - defer os.RemoveAll(baseDir) + baseDir := "base" + fsys := fstest.MapFS{ + filepath.Join(baseDir, featureFileName): { + Data: []byte(eatGodogContents), + Mode: fs.FileMode(0644), + }, + } - require.Nil(t, errA) - - err := ioutil.WriteFile(filepath.Join(baseDir, featureFileName), []byte(eatGodogContents), 0644) - require.Nil(t, err) - - featureFromFile, err := parser.ParseFeatures("", []string{baseDir}) + featureFromFile, err := parser.ParseFeatures(fsys, "", []string{baseDir}) require.NoError(t, err) require.Len(t, featureFromFile, 1) @@ -96,8 +96,9 @@ Feature: eat godogs } func Test_ParseFeatures_FromMultiplePaths(t *testing.T) { - const featureFileName = "godogs.feature" - const featureFileContents = `Feature: eat godogs + const ( + defaultFeatureFile = "godogs.feature" + defaultFeatureContents = `Feature: eat godogs In order to be happy As a hungry gopher I need to be able to eat godogs @@ -106,32 +107,74 @@ func Test_ParseFeatures_FromMultiplePaths(t *testing.T) { Given there are 12 godogs When I eat 5 Then there should be 7 remaining` + ) - baseDir := filepath.Join(os.TempDir(), t.Name(), "godogs") - errA := os.MkdirAll(baseDir+"/a", 0755) - errB := os.MkdirAll(baseDir+"/b", 0755) - defer os.RemoveAll(baseDir) + tests := map[string]struct { + fsys fs.FS + paths []string - require.Nil(t, errA) - require.Nil(t, errB) + expFeatures int + expError error + }{ + "feature directories can be parsed": { + paths: []string{"base/a", "base/b"}, + fsys: fstest.MapFS{ + filepath.Join("base/a", defaultFeatureFile): { + Data: []byte(defaultFeatureContents), + }, + filepath.Join("base/b", defaultFeatureFile): { + Data: []byte(defaultFeatureContents), + }, + }, + expFeatures: 2, + }, + "path not found errors": { + fsys: fstest.MapFS{}, + paths: []string{"base/a", "base/b"}, + expError: errors.New(`feature path "base/a" is not available`), + }, + "feature files can be parsed": { + paths: []string{ + filepath.Join("base/a/", defaultFeatureFile), + filepath.Join("base/b/", defaultFeatureFile), + }, + fsys: fstest.MapFS{ + filepath.Join("base/a", defaultFeatureFile): { + Data: []byte(defaultFeatureContents), + }, + filepath.Join("base/b", defaultFeatureFile): { + Data: []byte(defaultFeatureContents), + }, + }, + expFeatures: 2, + }, + } - err := ioutil.WriteFile(filepath.Join(baseDir+"/a", featureFileName), []byte(featureFileContents), 0644) - require.Nil(t, err) - err = ioutil.WriteFile(filepath.Join(baseDir+"/b", featureFileName), []byte(featureFileContents), 0644) - require.Nil(t, err) + for name, test := range tests { + test := test + t.Run(name, func(t *testing.T) { + t.Parallel() - features, err := parser.ParseFeatures("", []string{baseDir + "/a", baseDir + "/b"}) - assert.Nil(t, err) - assert.Len(t, features, 2) - - pickleIDs := map[string]bool{} - for _, f := range features { - for _, p := range f.Pickles { - if pickleIDs[p.Id] { - assert.Failf(t, "found duplicate pickle ID", "Pickle ID %s was already used", p.Id) + features, err := parser.ParseFeatures(test.fsys, "", test.paths) + if test.expError != nil { + require.Error(t, err) + require.EqualError(t, err, test.expError.Error()) + return } - pickleIDs[p.Id] = true - } + assert.Nil(t, err) + assert.Len(t, features, test.expFeatures) + + pickleIDs := map[string]bool{} + for _, f := range features { + for _, p := range f.Pickles { + if pickleIDs[p.Id] { + assert.Failf(t, "found duplicate pickle ID", "Pickle ID %s was already used", p.Id) + } + + pickleIDs[p.Id] = true + } + } + }) } } diff --git a/internal/storage/fs.go b/internal/storage/fs.go new file mode 100644 index 0000000..333c61d --- /dev/null +++ b/internal/storage/fs.go @@ -0,0 +1,21 @@ +package storage + +import ( + "io/fs" + "os" +) + +// FS is a wrapper that falls back to `os`. +type FS struct { + FS fs.FS +} + +// Open a file in the provided `fs.FS`. If none provided, +// open via `os.Open` +func (f FS) Open(name string) (fs.File, error) { + if f.FS == nil { + return os.Open(name) + } + + return f.FS.Open(name) +} diff --git a/internal/storage/fs_test.go b/internal/storage/fs_test.go new file mode 100644 index 0000000..164fe4d --- /dev/null +++ b/internal/storage/fs_test.go @@ -0,0 +1,109 @@ +package storage_test + +import ( + "errors" + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/cucumber/godog/internal/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStorage_Open_FS(t *testing.T) { + tests := map[string]struct { + fs fs.FS + + expData []byte + expError error + }{ + "normal open": { + fs: fstest.MapFS{ + "testfile": { + Data: []byte("hello worlds"), + }, + }, + expData: []byte("hello worlds"), + }, + "file not found": { + fs: fstest.MapFS{}, + expError: errors.New("open testfile: file does not exist"), + }, + "nil fs falls back on os": { + expError: errors.New("open testfile: no such file or directory"), + }, + } + + for name, test := range tests { + test := test + t.Run(name, func(t *testing.T) { + t.Parallel() + + f, err := (storage.FS{FS: test.fs}).Open("testfile") + if test.expError != nil { + assert.Error(t, err) + assert.EqualError(t, err, test.expError.Error()) + return + } + + assert.NoError(t, err) + + bb := make([]byte, len(test.expData)) + _, _ = f.Read(bb) + assert.Equal(t, test.expData, bb) + }) + } +} + +func TestStorage_Open_OS(t *testing.T) { + tests := map[string]struct { + files map[string][]byte + expData []byte + expError error + }{ + "normal open": { + files: map[string][]byte{ + "testfile": []byte("hello worlds"), + }, + expData: []byte("hello worlds"), + }, + "nil fs falls back on os": { + expError: errors.New("open /tmp/TestStorage_Open_OS/nil_fs_falls_back_on_os/godogs/testfile: no such file or directory"), + }, + } + + for name, test := range tests { + test := test + t.Run(name, func(t *testing.T) { + t.Parallel() + + baseDir := filepath.Join(os.TempDir(), t.Name(), "godogs") + err := os.MkdirAll(baseDir+"/a", 0755) + defer os.RemoveAll(baseDir) + + require.Nil(t, err) + + for name, data := range test.files { + err := ioutil.WriteFile(filepath.Join(baseDir, name), data, 0644) + require.NoError(t, err) + } + + f, err := (storage.FS{}).Open(filepath.Join(baseDir, "testfile")) + if test.expError != nil { + assert.Error(t, err) + assert.EqualError(t, err, test.expError.Error()) + return + } + + assert.NoError(t, err) + + bb := make([]byte, len(test.expData)) + _, _ = f.Read(bb) + assert.Equal(t, test.expData, bb) + }) + } +} diff --git a/run.go b/run.go index 7caa36e..09fdee0 100644 --- a/run.go +++ b/run.go @@ -6,6 +6,7 @@ import ( "fmt" "go/build" "io" + "io/fs" "math/rand" "os" "path/filepath" @@ -15,7 +16,7 @@ import ( "sync" "testing" - "github.com/cucumber/messages/go/v21" + messages "github.com/cucumber/messages/go/v21" "github.com/cucumber/godog/colors" "github.com/cucumber/godog/formatters" @@ -215,7 +216,15 @@ func runWithOptions(suiteName string, runner runner, opt Options) int { } if len(opt.Paths) == 0 && len(opt.FeatureContents) == 0 { - inf, err := os.Stat("features") + inf, err := func() (fs.FileInfo, error) { + file, err := opt.FS.Open("features") + if err != nil { + return nil, err + } + defer file.Close() + + return file.Stat() + }() if err == nil && inf.IsDir() { opt.Paths = []string{"features"} } @@ -226,6 +235,7 @@ func runWithOptions(suiteName string, runner runner, opt Options) int { } runner.fmt = multiFmt.FormatterFunc(suiteName, output) + opt.FS = storage.FS{FS: opt.FS} if len(opt.FeatureContents) > 0 { features, err := parser.ParseFromBytes(opt.Tags, opt.FeatureContents) @@ -237,7 +247,7 @@ func runWithOptions(suiteName string, runner runner, opt Options) int { } if len(opt.Paths) > 0 { - features, err := parser.ParseFeatures(opt.Tags, opt.Paths) + features, err := parser.ParseFeatures(opt.FS, opt.Tags, opt.Paths) if err != nil { fmt.Fprintln(os.Stderr, err) return exitOptionError @@ -325,6 +335,9 @@ func (ts TestSuite) Run() int { return exitOptionError } } + if ts.Options.FS == nil { + ts.Options.FS = storage.FS{} + } if ts.Options.ShowHelp { flag.CommandLine.Usage() @@ -349,13 +362,21 @@ func (ts TestSuite) RetrieveFeatures() ([]*models.Feature, error) { } if len(opt.Paths) == 0 { - inf, err := os.Stat("features") + inf, err := func() (fs.FileInfo, error) { + file, err := opt.FS.Open("features") + if err != nil { + return nil, err + } + defer file.Close() + + return file.Stat() + }() if err == nil && inf.IsDir() { opt.Paths = []string{"features"} } } - return parser.ParseFeatures(opt.Tags, opt.Paths) + return parser.ParseFeatures(opt.FS, opt.Tags, opt.Paths) } func getDefaultOptions() (*Options, error) { @@ -369,6 +390,7 @@ func getDefaultOptions() (*Options, error) { } opt.Paths = flagSet.Args() + opt.FS = storage.FS{} return opt, nil } diff --git a/run_test.go b/run_test.go index 792da0b..874d014 100644 --- a/run_test.go +++ b/run_test.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "io/fs" "io/ioutil" "os" "path/filepath" @@ -12,9 +13,10 @@ import ( "strconv" "strings" "testing" + "testing/fstest" - "github.com/cucumber/gherkin/go/v26" - "github.com/cucumber/messages/go/v21" + gherkin "github.com/cucumber/gherkin/go/v26" + messages "github.com/cucumber/messages/go/v21" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -751,3 +753,39 @@ func parseSeed(str string) (seed int64) { return } + +func Test_TestSuite_RetreiveFeatures(t *testing.T) { + tests := map[string]struct { + fsys fs.FS + + expFeatures int + }{ + "standard features": { + fsys: fstest.MapFS{ + "features/test.feature": { + Data: []byte(`Feature: test retrieve features + To test the feature + I must use this feature + + Scenario: Test function RetrieveFeatures + Given I create a TestSuite + When I call TestSuite.RetrieveFeatures + Then I should have one feature`), + }, + }, + expFeatures: 1, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + features, err := TestSuite{ + Name: "succeed", + Options: &Options{FS: test.fsys}, + }.RetrieveFeatures() + + assert.NoError(t, err) + assert.Equal(t, test.expFeatures, len(features)) + }) + } +} diff --git a/suite_context_test.go b/suite_context_test.go index 4da8f63..e25b3bb 100644 --- a/suite_context_test.go +++ b/suite_context_test.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" - "github.com/cucumber/gherkin/go/v26" - "github.com/cucumber/messages/go/v21" + gherkin "github.com/cucumber/gherkin/go/v26" + messages "github.com/cucumber/messages/go/v21" "github.com/stretchr/testify/assert" "github.com/cucumber/godog/colors" @@ -558,7 +558,7 @@ func (tc *godogFeaturesScenario) featurePath(path string) { } func (tc *godogFeaturesScenario) parseFeatures() error { - fts, err := parser.ParseFeatures("", tc.paths) + fts, err := parser.ParseFeatures(storage.FS{}, "", tc.paths) if err != nil { return err }