diff --git a/README.md b/README.md index 25f82c9..05772ed 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,6 @@ A collection of small, miscellaneous plugins for [Testo framework](https://githu - [Rerun](./rerun) - adds `--last-failed`-like behaviour from Pytest to Testo. Makes it possible to rerun only failed tests. - [XFail](./xfail) - adds `t.XFail()` method to mark a test as "expected to fail". - [Parallel](./parallel) - marks all tests as parallel by default. +- [Environment](./environment) - loads environment variables from `.env` files for testing. See also [Allure plugin for Testo](https://github.com/ozontech/testo-allure) - visualize results of a test run with [Allure Report](https://allurereport.org/). diff --git a/environment/README.md b/environment/README.md new file mode 100644 index 0000000..0469a4f --- /dev/null +++ b/environment/README.md @@ -0,0 +1,70 @@ +# Testo Environment Plugin + +A testo plugin that loads environment variables from `.env` files for testing. + +## Usage + +```go +import ( + "github.com/ozontech/testo" + "github.com/ozontech/testo-toppings/environment" + "github.com/ozontech/testo/testoplugin" +) + +func Test(t *testing.T) { + options := []testoplugin.Option{ + environment.WithEnvironments(".env", "config.env"), + } + + testo.RunSuite(t, new(Suite), options...) +} +``` + +## Features + +- Loads environment variables from `.env` files +- Sets environment variables in `BeforeAll` hook +- Supports multiple `.env` files (later files overwrite earlier ones) +- Handles comments (`#`) and empty lines +- Defaults to loading `.env` from the current directory if no files specified + +## Default File Loading + +By default, the plugin loads `.env` from the current directory when no files are specified: +```go +func Test(t *testing.T) { + options := []testoplugin.Option{ + environment.WithEnvironments(), // Loads .env automatically + } + testo.RunSuite(t, new(Suite), options...) +} +``` + +## .env File Format + +``` +# This is a comment +KEY1=VALUE1 +KEY2="quoted value" +KEY3=VALUE3 +``` + +## Multiple Files + +When multiple files are provided, values from later files overwrite earlier ones: +```go +environment.WithEnvironments("base.env", "override.env") +``` + +In this case, `override.env` values take precedence over `base.env` values. + +## Custom Default Files + +You can customize the default files loaded when no explicit files are specified: +```go +package environment + +var DefaultEnviroments = []string{".env", "config.env"} +``` + +This will load both `.env` and `config.env` when `WithEnvironments()` is called without arguments. diff --git a/environment/examples/01_basic/Makefile b/environment/examples/01_basic/Makefile new file mode 100644 index 0000000..cc8ee4c --- /dev/null +++ b/environment/examples/01_basic/Makefile @@ -0,0 +1,4 @@ +MAKEFLAGS += --always-make + +test: + go test . -v -tags example -count=1 diff --git a/environment/examples/01_basic/main_test.go b/environment/examples/01_basic/main_test.go new file mode 100644 index 0000000..e6e2e6e --- /dev/null +++ b/environment/examples/01_basic/main_test.go @@ -0,0 +1,47 @@ +//go:build example + +package main + +import ( + "os" + "testing" + + "github.com/ozontech/testo" + "github.com/ozontech/testo-toppings/environment" + "github.com/ozontech/testo/testoplugin" +) + +type T struct { + *testo.T + *environment.PluginEnvironment +} + +type Suite struct{ testo.Suite[T] } + +func (Suite) TestA(t T) { + testo.Run(t, "KEY-VALUE-1", func(t T) { + val := os.Getenv("KEY1") + if val != "VALUE1" { + t.FailNow() + } + }) +} + +func (Suite) TestB(t T) { + testo.Run(t, "KEY-VALUE-2", func(t T) { + val := os.Getenv("KEY2") + if val != "VALUE2" { + t.FailNow() + } + }) + +} + +func Test(t *testing.T) { + + options := []testoplugin.Option{ + environment.WithEnvironments(".env"), + } + + testo.RunSuite(t, new(Suite), options...) +} diff --git a/environment/examples/02_many_files/Makefile b/environment/examples/02_many_files/Makefile new file mode 100644 index 0000000..cc8ee4c --- /dev/null +++ b/environment/examples/02_many_files/Makefile @@ -0,0 +1,4 @@ +MAKEFLAGS += --always-make + +test: + go test . -v -tags example -count=1 diff --git a/environment/examples/02_many_files/basic.env b/environment/examples/02_many_files/basic.env new file mode 100644 index 0000000..e72d17e --- /dev/null +++ b/environment/examples/02_many_files/basic.env @@ -0,0 +1,2 @@ +KEY1=VALUE1 +KEY2=VALUE2 \ No newline at end of file diff --git a/environment/examples/02_many_files/main_test.go b/environment/examples/02_many_files/main_test.go new file mode 100644 index 0000000..d615116 --- /dev/null +++ b/environment/examples/02_many_files/main_test.go @@ -0,0 +1,46 @@ +//go:build example + +package main + +import ( + "os" + "testing" + + "github.com/ozontech/testo" + "github.com/ozontech/testo-toppings/environment" + "github.com/ozontech/testo/testoplugin" +) + +type T struct { + *testo.T + *environment.PluginEnvironment +} + +type Suite struct{ testo.Suite[T] } + +func (Suite) TestA(t T) { + testo.Run(t, "KEY-VALUE-1", func(t T) { + val := os.Getenv("KEY1") + if val != "STAND_VALUE1" { + t.FailNow() + } + }) + + testo.Run(t, "KEY-VALUE-2", func(t T) { + val := os.Getenv("KEY2") + if val != "VALUE2" { + t.FailNow() + } + }) +} + +func Test(t *testing.T) { + options := []testoplugin.Option{ + environment.WithEnvironments( + "basic.env", + "stand.env", + ), + } + + testo.RunSuite(t, new(Suite), options...) +} diff --git a/environment/examples/02_many_files/stand.env b/environment/examples/02_many_files/stand.env new file mode 100644 index 0000000..3852fa8 --- /dev/null +++ b/environment/examples/02_many_files/stand.env @@ -0,0 +1,2 @@ +KEY1=STAND_VALUE1 +KEY2=VALUE2 \ No newline at end of file diff --git a/environment/examples/03_set_defaults/Makefile b/environment/examples/03_set_defaults/Makefile new file mode 100644 index 0000000..cc8ee4c --- /dev/null +++ b/environment/examples/03_set_defaults/Makefile @@ -0,0 +1,4 @@ +MAKEFLAGS += --always-make + +test: + go test . -v -tags example -count=1 diff --git a/environment/examples/03_set_defaults/default.env b/environment/examples/03_set_defaults/default.env new file mode 100644 index 0000000..e72d17e --- /dev/null +++ b/environment/examples/03_set_defaults/default.env @@ -0,0 +1,2 @@ +KEY1=VALUE1 +KEY2=VALUE2 \ No newline at end of file diff --git a/environment/examples/03_set_defaults/suite_a_test.go b/environment/examples/03_set_defaults/suite_a_test.go new file mode 100644 index 0000000..af629d7 --- /dev/null +++ b/environment/examples/03_set_defaults/suite_a_test.go @@ -0,0 +1,47 @@ +//go:build example + +package main + +import ( + "os" + "testing" + + "github.com/ozontech/testo" + "github.com/ozontech/testo-toppings/environment" + "github.com/ozontech/testo/testoplugin" +) + +func init() { + environment.DefaultEnviroments = []string{"default.env"} +} + +type T struct { + *testo.T + *environment.PluginEnvironment +} + +type SuiteA struct{ testo.Suite[T] } + +func (SuiteA) TestA(t T) { + testo.Run(t, "KEY-VALUE-1", func(t T) { + val := os.Getenv("KEY1") + if val != "VALUE1" { + t.FailNow() + } + }) + + testo.Run(t, "KEY-VALUE-2", func(t T) { + val := os.Getenv("KEY2") + if val != "VALUE2" { + t.FailNow() + } + }) +} + +func TestA(t *testing.T) { + options := []testoplugin.Option{ + environment.WithEnvironments(), + } + + testo.RunSuite(t, new(SuiteA), options...) +} diff --git a/environment/examples/03_set_defaults/suite_b_test.go b/environment/examples/03_set_defaults/suite_b_test.go new file mode 100644 index 0000000..935c41f --- /dev/null +++ b/environment/examples/03_set_defaults/suite_b_test.go @@ -0,0 +1,38 @@ +//go:build example + +package main + +import ( + "os" + "testing" + + "github.com/ozontech/testo" + "github.com/ozontech/testo-toppings/environment" + "github.com/ozontech/testo/testoplugin" +) + +type SuiteB struct{ testo.Suite[T] } + +func (SuiteB) TestA(t T) { + testo.Run(t, "KEY-VALUE-1", func(t T) { + val := os.Getenv("KEY1") + if val != "VALUE1" { + t.FailNow() + } + }) + + testo.Run(t, "KEY-VALUE-2", func(t T) { + val := os.Getenv("KEY2") + if val != "VALUE2" { + t.FailNow() + } + }) +} + +func TestB(t *testing.T) { + options := []testoplugin.Option{ + environment.WithEnvironments(), + } + + testo.RunSuite(t, new(SuiteA), options...) +} diff --git a/environment/options.go b/environment/options.go new file mode 100644 index 0000000..521f5fb --- /dev/null +++ b/environment/options.go @@ -0,0 +1,17 @@ +package environment + +import "github.com/ozontech/testo/testoplugin" + +type option func(p *PluginEnvironment) + +// WithEnvironments specifies which .env files to load. +// Later files overwrite earlier files for the same environment variable. +// If no files are specified, defaults to loading .env from the current directory. +func WithEnvironments(filenames ...string) testoplugin.Option { + return testoplugin.Option{ + Value: option(func(p *PluginEnvironment) { + p.filenames = filenames + }), + Propagate: true, + } +} diff --git a/environment/plugin.go b/environment/plugin.go new file mode 100644 index 0000000..a673d1e --- /dev/null +++ b/environment/plugin.go @@ -0,0 +1,116 @@ +package environment + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/ozontech/testo" + "github.com/ozontech/testo/testoplugin" +) + +// DefaultEnviroments is the default list of .env files to load. +// By default, it loads the .env file from the current directory when no files are specified. +var DefaultEnviroments = []string{".env"} + +var _ testoplugin.Plugin = (*PluginEnvironment)(nil) + +type PluginEnvironment struct { + *testo.T + filenames []string +} + +// Plugin implements [testoplugin.Plugin]. +// Loads environment variables from .env files before all tests run. +// Later files overwrite earlier files for the same key. +// If no files are specified, loads the default .env file from the current directory. +func (p *PluginEnvironment) Plugin( + _ testoplugin.Plugin, + options ...testoplugin.Option, +) testoplugin.Spec { + + for _, opt := range options { + if o, ok := opt.Value.(option); ok { + o(p) + } + } + + if len(p.filenames) == 0 { + p.filenames = append(p.filenames, DefaultEnviroments...) + } + + return testoplugin.Spec{ + Hooks: p.hooks(), + } +} + +func (p *PluginEnvironment) hooks() testoplugin.Hooks { + envs := map[string]string{} + + // Read environment variables from all specified files. + // Later files overwrite earlier files for the same key. + for _, fileName := range p.filenames { + err := appendValuesFromEnv(envs, fileName) + if err != nil { + p.T.Errorf("Failed to load .env file %s: %v", fileName, err) + } + } + + return testoplugin.Hooks{ + BeforeAll: testoplugin.Hook{ + Func: func() { + // Set all environment variables before running any tests + for k, v := range envs { + p.T.Setenv(k, v) + } + }, + }, + } +} + +// appendValuesFromEnv appends environment variables from a .env file to the envs map. +// Lines starting with # or empty lines are ignored. +// Values are trimmed and outer quotes are removed (e.g., "value" -> value). +// Later lines in the file take precedence over earlier lines for the same key. +func appendValuesFromEnv(envs map[string]string, path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", path, err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("line %d in %s: expected KEY=VALUE format", lineNum, path) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove surrounding quotes from value + if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'')) { + value = value[1 : len(value)-1] + } + + envs[key] = value + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading file %s: %w", path, err) + } + + return nil +}