Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,39 @@ Obviously this example is missing a lot of detail, but you can refer to the code

Coming soon!

## Testing

Confire ships with some testing helper functions so that you can test your configuration's specific configuration or loading functionality. To manage the environment for a test:

```go
env := contest.Env{
"MYAPP_DEBUG": "true",
"MYAPP_PORT": "8888",
"MYAPP_TIMEOUT": "5s",
"MYAPP_RATE": "0.25",
"MYAPP_COLORS": "red:1,green:2,blue:3",
"MYAPP_PEERS": "alpha,bravo,charlie",
}

func TestMyConf(t *testing.T) {
// Will set the environment variables describe by env and clean them back up to the
// original state after the test is completed.
t.Cleanup(env.Set())
}
```

You can also clear the environment of the specified environment variables to check validation rules or to specify a specific subset of keys:

```go
func TestConfValidation(t *testing.T) {
t.Cleanup(env.Clear())
}

func TestSubsetOfKeys(t *testing.T) {
t.Cleanup(env.Set("MYAPP_TIMEOUT", "MYAPP_RATE", "MYAPP_PEERS"))
}
```

## Credits

Special thanks to the following libraries for providing inspiration and code snippets using their open sources licenses:
Expand Down
32 changes: 32 additions & 0 deletions assert/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package assert

import (
"errors"
"os"
"reflect"
"testing"
)
Expand Down Expand Up @@ -46,6 +47,15 @@ func Ok(tb testing.TB, err error) {
}
}

// NotOk fails the test if an err is nil.
func NotOk(tb testing.TB, err error) {
tb.Helper()
if err == nil {
tb.Logf("\nexpected error to be non-nil")
tb.FailNow()
}
}

// Equals fails the test if exp (expected) is not equal to act (actual).
func Equals(tb testing.TB, exp, act interface{}) {
tb.Helper()
Expand All @@ -60,3 +70,25 @@ func ErrorIs(tb testing.TB, err, target error) {
tb.Helper()
Assert(tb, errors.Is(err, target), "expected target to be in error chain")
}

// EnvUnset asserts that the environment variable is not set.
func EnvUnset(tb testing.TB, key string) {
tb.Helper()
_, ok := os.LookupEnv(key)
Assert(tb, !ok, "expected environment variable %s to be unset", key)
}

// EnvIsSet asserts that the environment variable is set.
func EnvIsSet(tb testing.TB, key string) {
tb.Helper()
_, ok := os.LookupEnv(key)
Assert(tb, ok, "expected environment variable %s to be set", key)
}

// EnvEquals asserts that the environment variable is set and equals the expected value.
func EnvEquals(tb testing.TB, key, exp string) {
tb.Helper()
act, isSet := os.LookupEnv(key)
Assert(tb, isSet, "expected environment variable %s to be set", key)
Assert(tb, reflect.DeepEqual(exp, act), "expected environment variable %s to be set as\n\n\t- exp: %#v\n\t- got: %#v\n", key, exp, act)
}
232 changes: 232 additions & 0 deletions confire_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,233 @@
package confire_test

import (
"encoding/hex"
"strings"
"testing"
"time"

"go.rtnl.ai/confire"
"go.rtnl.ai/confire/assert"
"go.rtnl.ai/confire/contest"
)

//============================================================================
// Configuration Types
//============================================================================

// Test Configuration
type Config struct {
Debug bool `default:"false" desc:"enable debug mode"`
ServiceName string `required:"true" split_words:"true" desc:"name of the service"`
Host string `default:"0.0.0.0" desc:"host to listen on"`
Port int `default:"8080" desc:"port to listen on"`
Rate float64 `default:"1.0" desc:"percentage of requests to process"`
Timeout time.Duration `default:"10s" desc:"timeout for requests"`
LogLevel LevelDecoder `env:"OTEL_LOG_LEVEL" default:"info" desc:"log level"`
Database DatabaseConfig
UI UIConfig
}

type DatabaseConfig struct {
URL string `env:"DATABASE_URL" required:"true" desc:"database connection URL"`
ReadOnly bool `default:"false" desc:"read only mode"`
}

type UIConfig struct {
Enabled bool `default:"true" desc:"enable the customized user interface"`
Primary Color `default:"#cc6699" desc:"primary color"`
Secondary Color `default:"#eeffee" desc:"secondary color"`
Palette map[string]Color `desc:"palette of colors"`
}

func (c Config) Validate() (err error) {
if c.Port < 1024 || c.Port > 65535 {
err = confire.Join(err, confire.Invalid("", "port", "must be in the integer range [1024, 65535]"))
}

if c.Rate < 0.0 || c.Rate > 1.0 {
err = confire.Join(err, confire.Invalid("", "rate", "must be in the float range [0.0, 1.0]"))
}

return err
}

func (c *UIConfig) Validate() (err error) {
if c.Enabled {
if len(c.Palette) == 0 {
err = confire.Join(err, confire.Required("ui", "palette"))
}

if len(c.Palette) > 8 {
err = confire.Join(err, confire.Invalid("ui", "palette", "palette must be less than 8 colors"))
}

hasPrimary, hasSecondary := false, false
for _, color := range c.Palette {
if color == c.Primary {
hasPrimary = true
}
if color == c.Secondary {
hasSecondary = true
}
}

if !hasPrimary {
err = confire.Join(err, confire.Invalid("ui", "palette", "primary color must be included in the palette"))
}

if !hasSecondary {
err = confire.Join(err, confire.Invalid("ui", "palette", "secondary color must be included in the palette"))
}
}
return err
}

type LevelDecoder uint8

const (
LevelDebug LevelDecoder = iota
LevelInfo
LevelWarning
LevelError
LevelFatal
LevelPanic
)

func (l *LevelDecoder) Decode(value string) error {
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "debug":
*l = LevelDebug
case "info":
*l = LevelInfo
case "warning":
*l = LevelWarning
case "error":
*l = LevelError
case "fatal":
*l = LevelFatal
case "panic":
*l = LevelPanic
default:
return confire.Invalid("", "log level", "invalid log level: %q", value)
}
return nil
}

type Color [3]uint8

func (c *Color) Decode(value string) error {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.TrimPrefix(value, "#")

if len(value) != 6 {
return confire.Invalid("", "color", "invalid color: %q", value)
}

if n, err := hex.Decode(c[:], []byte(value)); n != 3 || err != nil {
return confire.Invalid("", "color", "could not decode hex color")
}
return nil
}

//============================================================================
// Configuration Tests
//============================================================================

var testEnv = contest.Env{
"CONFIRE_DEBUG": "true",
"CONFIRE_SERVICE_NAME": "myapp",
"CONFIRE_HOST": "127.0.0.1",
"CONFIRE_PORT": "8000",
"CONFIRE_RATE": "0.75",
"CONFIRE_TIMEOUT": "1m30s",
"OTEL_LOG_LEVEL": "warning",
"DATABASE_URL": "sqlite://myapp.db",
"CONFIRE_DATABASE_READONLY": "true",
"CONFIRE_UI_ENABLED": "true",
"CONFIRE_UI_PRIMARY": "#ff0000",
"CONFIRE_UI_SECONDARY": "#00ff00",
"CONFIRE_UI_PALETTE": "primary:#ff0000,secondary:#00ff00",
}

var validConfig = Config{
Debug: true,
ServiceName: "myapp",
Host: "127.0.0.1",
Port: 8000,
Rate: 0.75,
Timeout: time.Minute + (30 * time.Second),
LogLevel: LevelWarning,
Database: DatabaseConfig{
URL: "sqlite://myapp.db",
ReadOnly: true,
},
UI: UIConfig{
Enabled: true,
Primary: Color{255, 0, 0},
Secondary: Color{0, 255, 0},
Palette: map[string]Color{
"primary": {255, 0, 0},
"secondary": {0, 255, 0},
},
},
}

func TestConfig(t *testing.T) {
t.Cleanup(testEnv.Set())

var conf Config
err := confire.Process("confire", &conf)
assert.Ok(t, err)

assert.Equals(t, validConfig, conf)
}

func TestValidation(t *testing.T) {

t.Run("Defaults", func(t *testing.T) {
t.Cleanup(testEnv.Clear())

var conf Config
err := confire.Process("confire", &conf)
assert.NotOk(t, err)
assert.True(t, confire.IsValidationErrors(err))

errs, ok := confire.ValidationErrors(err)
assert.True(t, ok)
assert.Assert(t, len(errs) == 5, "expected 5 validation errors got %d", len(errs))
assert.Equals(t, "invalid configuration: ServiceName is required but not set", errs[0].Error())
assert.Equals(t, "invalid configuration: URL is required but not set", errs[1].Error())
assert.Equals(t, "invalid configuration: ui.palette is required but not set", errs[2].Error())
assert.Equals(t, "invalid configuration: ui.palette primary color must be included in the palette", errs[3].Error())
assert.Equals(t, "invalid configuration: ui.palette secondary color must be included in the palette", errs[4].Error())
})

t.Run("SoWrong", func(t *testing.T) {
env := contest.Env{
"CONFIRE_PORT": "22",
"CONFIRE_RATE": "1.2",
"DATABASE_URL": "",
"CONFIRE_UI_ENABLED": "true",
"CONFIRE_UI_PALETTE": "primary:#000000,secondary:#000000,warning:#000000,error:#000000,fatal:#000000,panic:#000000",
}

t.Cleanup(env.Set())

var conf Config
err := confire.Process("confire", &conf)
assert.NotOk(t, err)
assert.Assert(t, confire.IsValidationErrors(err), "expected validation errors got %s", err.Error())

errs, ok := confire.ValidationErrors(err)
assert.True(t, ok)
assert.Assert(t, len(errs) == 6, "expected 6 validation errors got %d", len(errs))
assert.Equals(t, "invalid configuration: port must be in the integer range [1024, 65535]", errs[0].Error())
assert.Equals(t, "invalid configuration: rate must be in the float range [0.0, 1.0]", errs[1].Error())
assert.Equals(t, "invalid configuration: ServiceName is required but not set", errs[2].Error())
assert.Equals(t, "invalid configuration: URL is required but not set", errs[3].Error())
assert.Equals(t, "invalid configuration: ui.palette primary color must be included in the palette", errs[4].Error())
assert.Equals(t, "invalid configuration: ui.palette secondary color must be included in the palette", errs[5].Error())
})
}
Loading
Loading