Use fs.FS abstraction for filesystem (#550)

* compiles

* mock fs in tests

* fix parser tests

* fix run.go

* rename FeatureFS to FS

* fix docs typos

* remove debug log

* add os.DirFS("./") to default options

* reword docstring

* add fs wrapper

* updated readme and changelog

* added note

* fix changelog

* remove ./ gating from defaults

* add new storage.FS tests

* increase coverage of parser.parsePath

* increase coverage of TestSuite.RetrieveFeatures

* remove another os.Stat

---------

Co-authored-by: Tighearnán Carroll <tighearnan.carroll@gamil.com>
Этот коммит содержится в:
Tighearnán Carroll 2023-03-27 20:52:51 +01:00 коммит произвёл GitHub
родитель 3bd9e9ca4f
коммит 6ce2b8696b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 331 добавлений и 59 удалений

Просмотреть файл

@ -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)

Просмотреть файл

@ -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.

1
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=

Просмотреть файл

@ -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
}

Просмотреть файл

@ -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):

Просмотреть файл

@ -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
}
}
})
}
}

21
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)
}

109
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)
})
}
}

32
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
}

Просмотреть файл

@ -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))
})
}
}

Просмотреть файл

@ -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
}