Repository: go-bdd/gobdd (go)

Issue:
Add WithFeaturesFS to use embedded feature files
**Is your feature request related to a problem? Please describe.**
GoBDD needs to read files from the filesystem at runtime which works, but could be better.

**Describe the solution you'd like**
Add a WithFeaturesFS funcion to use embedded feature files. This way files would be compiled into the test binary and tests would become more self-contained. Missing features would be noticed compile time.

**Additional context**
This isn't necessarily a great idea, but I had it and wanted to take a note. Maybe it's worth something.


Pull request description:
Add support for loading features from fs.FS
This PR adds backward compatible support for loading features from alternative sources (like fs.FS filesystems).

Fixes #143

**TODO**

- [ ] Update documentation

Patch:
diff --git a/docs/suite-options.md b/docs/suite-options.md
index 1b3d8ad..fd8975b 100644
--- a/docs/suite-options.md
+++ b/docs/suite-options.md
@@ -9,6 +9,7 @@ The suite can be confiugred using one of these functions:
 
 * `RunInParallel()` - enables running steps in parallel. It uses the stanard `T.Parallel` function.
 * `WithFeaturesPath(path string)` - configures the path where GoBDD should look for features. The default value is `features/*.feature`.
+* `WithFeaturesFS(fs fs.FS, path string)` - configures the filesystem and a path (glob pattern) where GoBDD should look for features.
 * `WithTags(tags ...string)` - configures which tags should be run. Every tag has to start with `@`.
 * `WithBeforeScenario(f func())` - this function `f` will be called before every scenario.
 * `WithAfterScenario(f func())` - this funcion `f` will be called after every scenario.
@@ -26,3 +27,19 @@ suite := NewSuite(t, WithFeaturesPath("features/func_types.feature"))
 suite := NewSuite(t, WithFeaturesPath("features/tags.feature"), WithTags([]string{"@tag"}))
 ```
 
+As of Go 1.16 you can embed feature files into the test binary and use `fs.FS` as a feature source:
+
+```go
+import (
+	"embed"
+)
+
+//go:embed features/*.feature
+var featuresFS embed.FS
+
+// ...
+
+suite := NewSuite(t, WithFeaturesFS(featuresFS, "*.feature"))
+```
+
+While in most cases it doesn't make any difference, embedding feature files makes your tests more portable.
diff --git a/features.go b/features.go
new file mode 100644
index 0000000..0569fcf
--- /dev/null
+++ b/features.go
@@ -0,0 +1,11 @@
+//go:build go1.16
+// +build go1.16
+
+package gobdd
+
+import (
+	"embed"
+)
+
+//go:embed features/*.feature
+var featuresFS embed.FS
diff --git a/go.mod b/go.mod
index f423d32..d0971ae 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/go-bdd/gobdd
 
-go 1.12
+go 1.16
 
 require (
 	github.com/cucumber/gherkin-go/v13 v13.0.0
diff --git a/gobdd.go b/gobdd.go
index be553ef..d9f8527 100644
--- a/gobdd.go
+++ b/gobdd.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"errors"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -27,7 +28,7 @@ type Suite struct {
 
 // SuiteOptions holds all the information about how the suite or features/steps should be configured
 type SuiteOptions struct {
-	featuresPaths  string
+	featureSource  featureSource
 	ignoreTags     []string
 	tags           []string
 	beforeScenario []func(ctx Context)
@@ -37,10 +38,46 @@ type SuiteOptions struct {
 	runInParallel  bool
 }
 
+type featureSource interface {
+	loadFeatures() ([]feature, error)
+}
+
+type feature interface {
+	Open() (io.Reader, error)
+}
+
+type pathFeatureSource string
+
+func (s pathFeatureSource) loadFeatures() ([]feature, error) {
+	files, err := filepath.Glob(string(s))
+	if err != nil {
+		return nil, errors.New("cannot find features/ directory")
+	}
+
+	features := make([]feature, 0, len(files))
+
+	for _, f := range files {
+		features = append(features, fileFeature(f))
+	}
+
+	return features, nil
+}
+
+type fileFeature string
+
+func (f fileFeature) Open() (io.Reader, error) {
+	file, err := os.Open(string(f))
+	if err != nil {
+		return nil, fmt.Errorf("cannot open file %s", f)
+	}
+
+	return file, nil
+}
+
 // NewSuiteOptions creates a new suite configuration with default values
 func NewSuiteOptions() SuiteOptions {
 	return SuiteOptions{
-		featuresPaths:  "features/*.feature",
+		featureSource:  pathFeatureSource("features/*.feature"),
 		ignoreTags:     []string{},
 		tags:           []string{},
 		beforeScenario: []func(ctx Context){},
@@ -61,7 +98,7 @@ func RunInParallel() func(*SuiteOptions) {
 // The default value is "features/*.feature"
 func WithFeaturesPath(path string) func(*SuiteOptions) {
 	return func(options *SuiteOptions) {
-		options.featuresPaths = path
+		options.featureSource = pathFeatureSource(path)
 	}
 }
 
@@ -141,7 +178,6 @@ type FeatureKey struct{}
 // ScenarioKey is used to store reference to current *msgs.GherkinDocument_Feature_Scenario instance
 type ScenarioKey struct{}
 
-
 // Creates a new suites with given configuration and empty steps defined
 func NewSuite(t TestingT, optionClosures ...func(*SuiteOptions)) *Suite {
 	options := NewSuiteOptions()
@@ -268,32 +304,36 @@ func (s *Suite) Run() {
 		return
 	}
 
-	files, err := filepath.Glob(s.options.featuresPaths)
+	features, err := s.options.featureSource.loadFeatures()
 	if err != nil {
-		s.t.Fatalf("cannot find features/ directory")
+		s.t.Fatalf(err.Error())
 	}
 
 	if s.options.runInParallel {
 		s.t.Parallel()
 	}
 
-	for _, file := range files {
-		err = s.executeFeature(file)
+	for _, feature := range features {
+		err = s.executeFeature(feature)
 		if err != nil {
 			s.t.Fail()
 		}
 	}
 }
 
-func (s *Suite) executeFeature(file string) error {
-	f, err := os.Open(file)
+func (s *Suite) executeFeature(feature feature) error {
+  f, err := feature.Open()
 	if err != nil {
-		return fmt.Errorf("cannot open file %s", file)
+		return err
 	}
-	defer f.Close()
-	fileIO := bufio.NewReader(f)
 
-	doc, err := gherkin.ParseGherkinDocument(fileIO, (&msgs.Incrementing{}).NewId)
+  if closer, ok := f.(io.Closer); ok {
+    defer closer.Close()
+  }
+
+	featureIO := bufio.NewReader(f)
+
+	doc, err := gherkin.ParseGherkinDocument(featureIO, (&msgs.Incrementing{}).NewId)
 	if err != nil {
 		s.t.Fatalf("error while loading document: %s\n", err)
 	}
diff --git a/gobdd_go1_16.go b/gobdd_go1_16.go
new file mode 100644
index 0000000..8b3ceb2
--- /dev/null
+++ b/gobdd_go1_16.go
@@ -0,0 +1,57 @@
+//go:build go1.16
+// +build go1.16
+
+package gobdd
+
+import (
+	"fmt"
+	"io"
+	"io/fs"
+)
+
+// WithFeaturesFS configures a filesystem and a path (glob pattern) where features can be found.
+func WithFeaturesFS(fs fs.FS, path string) func(*SuiteOptions) {
+	return func(options *SuiteOptions) {
+		options.featureSource = fsFeatureSource{
+			fs:      fs,
+			path: path,
+		}
+	}
+}
+
+type fsFeatureSource struct {
+	fs   fs.FS
+	path string
+}
+
+func (s fsFeatureSource) loadFeatures() ([]feature, error) {
+	files, err := fs.Glob(s.fs, s.path)
+	if err != nil {
+		return nil, fmt.Errorf("loading features: %w", err)
+	}
+
+	features := make([]feature, 0, len(files))
+
+	for _, f := range files {
+		features = append(features, fsFeature{
+			fs:   s.fs,
+			file: f,
+		})
+	}
+
+	return features, nil
+}
+
+type fsFeature struct {
+	fs   fs.FS
+	file string
+}
+
+func (f fsFeature) Open() (io.Reader, error) {
+	file, err := f.fs.Open(f.file)
+	if err != nil {
+		return nil, fmt.Errorf("opening feature: %w", err)
+	}
+
+	return file, nil
+}


Test patch:
diff --git a/gobdd_go1_16_test.go b/gobdd_go1_16_test.go
new file mode 100644
index 0000000..3083599
--- /dev/null
+++ b/gobdd_go1_16_test.go
@@ -0,0 +1,19 @@
+//go:build go1.16
+// +build go1.16
+
+package gobdd
+
+import (
+	"regexp"
+	"testing"
+)
+
+func TestWithFeaturesFS(t *testing.T) {
+	suite := NewSuite(t, WithFeaturesFS(featuresFS, "example.feature"))
+	compiled := regexp.MustCompile(`I add (\d+) and (\d+)`)
+	suite.AddRegexStep(compiled, add)
+	compiled = regexp.MustCompile(`the result should equal (\d+)`)
+	suite.AddRegexStep(compiled, check)
+
+	suite.Run()
+}