Repository: cucumber/godog
Pull request #550 (2023-03-25 16:58:53)

Use `fs.FS` abstraction for filesystem
<!---
Thanks for helping to make Cucumber better! 💖

You can feel free to open a "Draft" status pull request if you're not ready for feedback yet.

Don't worry about getting everything perfect! We're here to help and will coach you through
to getting your pull request ready to merge.

The prompts below are for guidance to help you describe your change in a way that is most 
likely to make sense to other people when they are reviewing it. Still, it's just a guide, 
so feel free to delete anything that doesn't feel appropriate, and add anything additional 
that seems like it would provide useful context. 👏🏻
-->

### 🤔 What's changed?

<!-- Describe your changes in detail -->

Allow users to supply a `fs.FS` to files from. If not provided, we will fallback to the standard `os` file operations.

### ⚡️ What's your motivation? 

<!-- 
What motivated you to propose this change? Does it fix a bug? Add a new feature?
If it fixes an open issue, you can link to the issue here, e.g. "Fixes #99"
-->

As `fs.FS` is an interface it gives users flexibility beyond just the operating system's filesystem, such as mocking (see, for example, the tests. We no longer write data to `/tmp`) or at the very least, an in-memory fs. But the key feature is that it allows us to compile the feature files into an executable test binary, allowing that binary to be shipped independent from the feature files.

```go
//go:embed features/*
var featuresData embed.FS

...

var opts = godog.Options{
	Paths: []string{"features"},
	FS:    featureData,
}
```

Then we can just:
```sh
> go test -c ./test/integration/integration_test.go
> mv integration.test /some/random/dir
> cd /some/random/dir
> ./integration.test
```

Currently I embed all  test data into my test suite, and sometimes deploy these tests into a restricted environment for some non-user facing service tests in a live environment. With the current set up of godog, I have to also be concerned with shipping the feature files and placing them the correct location on the filesystem. This change would let users just fire a test binary into a scratch container and run without being concerned the underlying filesystem.

### 🏷️ What kind of change is this?

<!--- Delete any options that are not relevant -->

- :bank: Refactoring/debt/DX (improvement to code design, tooling, documentation etc. without changing behaviour)
- :zap: New feature (non-breaking change which adds new behaviour)


### 📋 Checklist:

<!--- 
This is to help you remember all the little things we often forget to do!

Feel free to delete any tasks that are not relevant, or add new ones.
-->

- [x] I agree to respect and uphold the [Cucumber Community Code of Conduct](https://cucumber.io/conduct/)
- [x] I've changed the behaviour of the code
  - [x] I have added/updated tests to cover my changes.
- [x] My change requires a change to the documentation.
  - [x] I have updated the documentation accordingly.
- [x] Users should know about my change
  - [x] I have added an entry to the "Unreleased" section of the [**CHANGELOG**](../CHANGELOG.md), linking to this pull request.

----

*This text was originally generated from a [template](https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/about-issue-and-pull-request-templates), then edited by hand. [You can modify the template here.](https://github.com/cucumber/.github/edit/main/.github/PULL_REQUEST_TEMPLATE.md)*


tigh-latte: Sure we can do that, I'll see to sorting this at some point today.
tigh-latte: That's done [here](https://github.com/cucumber/godog/pull/550/commits/7b48c258f05fbce3a27212034d832febcdfc7bed) @vearutop 
tigh-latte: @vearutop I fixed the build [here](https://github.com/cucumber/godog/pull/550/commits/879f4318c3a2a4df7f660f4807a1638c566630b4), sorry about that.
codecov[bot]: ## [Codecov](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber) Report
> Merging [#550](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber) (879f431) into [main](https://codecov.io/gh/cucumber/godog/commit/3bd9e9ca4fe22129922cc5a40dce28d7a96a7b8c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber) (3bd9e9c) will **decrease** coverage by `0.20%`.
> The diff coverage is `59.25%`.

```diff
@@            Coverage Diff             @@
##             main     #550      +/-   ##
==========================================
- Coverage   82.24%   82.05%   -0.20%     
==========================================
  Files          27       28       +1     
  Lines        3340     3354      +14     
==========================================
+ Hits         2747     2752       +5     
- Misses        484      492       +8     
- Partials      109      110       +1     
```


| [Impacted Files](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber) | Coverage Δ | |
|---|---|---|
| [internal/storage/fs.go](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber#diff-aW50ZXJuYWwvc3RvcmFnZS9mcy5nbw==) | `0.00% <0.00%> (ø)` | |
| [run.go](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber#diff-cnVuLmdv) | `74.27% <50.00%> (-0.21%)` | :arrow_down: |
| [internal/parser/parser.go](https://codecov.io/gh/cucumber/godog/pull/550?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber#diff-aW50ZXJuYWwvcGFyc2VyL3BhcnNlci5nbw==) | `65.62% <77.77%> (-0.39%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cucumber)

tigh-latte: Ok I've added covered the new `storage.FS` wrapper, and used `fstest.MapFS` to mock some file system failures to increase covered in certain areas. Hopefully Codecov will be happy now.

Patch:
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/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/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
 }


Test patch:
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)
-
-	require.Nil(t, errA)
-
-	err := ioutil.WriteFile(filepath.Join(baseDir, featureFileName), []byte(eatGodogContents), 0644)
-	require.Nil(t, err)
+	baseDir := "base"
+	fsys := fstest.MapFS{
+		filepath.Join(baseDir, featureFileName): {
+			Data: []byte(eatGodogContents),
+			Mode: fs.FileMode(0644),
+		},
+	}
 
-	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`
+	)
+
+	tests := map[string]struct {
+		fsys  fs.FS
+		paths []string
+
+		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,
+		},
+	}
 
-	baseDir := filepath.Join(os.TempDir(), t.Name(), "godogs")
-	errA := os.MkdirAll(baseDir+"/a", 0755)
-	errB := os.MkdirAll(baseDir+"/b", 0755)
-	defer os.RemoveAll(baseDir)
+	for name, test := range tests {
+		test := test
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
 
-	require.Nil(t, errA)
-	require.Nil(t, errB)
+			features, err := parser.ParseFeatures(test.fsys, "", test.paths)
+			if test.expError != nil {
+				require.Error(t, err)
+				require.EqualError(t, err, test.expError.Error())
+				return
+			}
 
-	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)
+			assert.Nil(t, err)
+			assert.Len(t, features, test.expFeatures)
 
-	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)
+					}
 
-	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
+				}
 			}
-
-			pickleIDs[p.Id] = true
-		}
+		})
 	}
 }
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_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
 	}