Skip to content

Commit

Permalink
add support for declaring dependencies for a Var
Browse files Browse the repository at this point in the history
Listing dependencies in Var.Deps helps us specify what's necessary for our Var to function properly.
This approach avoids relying on OnLet for automating dependency binding.
Additionally, if someone binds our Var, all dependent variables get bound too.
This isolates implementation details from tests, ensuring tests focus only on what they need, not the entire component dependency graph.
  • Loading branch information
adamluzsi committed Feb 24, 2024
1 parent ba50aff commit db0b18a
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 2 deletions.
21 changes: 21 additions & 0 deletions T.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package testcase

import (
"math/rand"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -67,6 +68,9 @@ type T struct {
tags map[string]struct{}
teardown *teardown.Teardown

depsInit sync.Once
deps map[string]struct{}

// TODO: protect it against concurrency
timerPaused bool

Expand Down Expand Up @@ -154,6 +158,23 @@ func (t *T) hasOnLetHookApplied(name string) bool {
return false
}

func (v Var[V]) initDeps(t *T) {
t.Helper()
t.vars.depsInitDo(v.ID, func() {
t.Helper()
for _, dep := range v.Deps {
_ = dep.get(t) // init
}
})
}

func (v Var[V]) letDeps(s *Spec) {
s.testingTB.Helper()
for _, dep := range v.Deps {
dep.bind(s)
}
}

var DefaultEventually = assert.Retry{Strategy: assert.Waiter{Timeout: 3 * time.Second}}

// Eventually helper allows you to write expectations to results that will only be eventually true.
Expand Down
32 changes: 30 additions & 2 deletions Var.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,25 @@ type Var[V any] struct {
// In case OnLet is provided, the Var must be explicitly set to a Spec with a Let call
// else accessing the Var value will panic and warn about this.
OnLet func(s *Spec, v Var[V])
// Deps allow you to define a list of Var, that this current Var is depending on.
// If any of the Vars in the dependency list has OnLet set, binding them will be possible by binding our Var.
// Deps make it convenient to describe the dependency graph of our Var without the need to use OnLet + Bind.
// This is especially ideal if we want to use adhoc variable loading in our test,
Deps Vars
}

type Vars []tetcaseVar

type tetcaseVar interface {
isTestcaseVar()
id() string
get(t *T) any
bind(s *Spec)
}

func (Var[V]) isTestcaseVar() {}
func (v Var[V]) id() string { return v.ID }

type VarInit[V any] func(*T) V

//func CastToVarInit[V any](fn func(testing.TB) V) func(*T) V {
Expand All @@ -62,13 +79,20 @@ const (
// When Go2 released, it will replace type casting
func (v Var[V]) Get(t *T) V {
t.Helper()
val, _ := v.get(t).(V)
return val
}

func (v Var[V]) get(t *T) any {
defer t.pauseTimer()()
t.Helper()
if v.ID == "" {
t.Fatalf(varIDIsIsMissing, v)
}
if v.OnLet != nil && !t.hasOnLetHookApplied(v.ID) {
t.Fatalf(varOnLetNotInitialized, v.ID)
}
v.initDeps(t)
v.execBefore(t)
if !t.vars.Knows(v.ID) && v.Init != nil {
t.vars.Let(v.ID, func(t *T) interface{} { return v.Init(t) })
Expand Down Expand Up @@ -102,8 +126,6 @@ func (v Var[V]) Let(s *Spec, blk VarInit[V]) Var[V] {
return let(s, v.ID, blk)
}

type letWithSuperBlock[V any] func(t *T, super V) V

func (v Var[V]) onLet(s *Spec) {
s.testingTB.Helper()
if v.OnLet != nil {
Expand All @@ -113,6 +135,7 @@ func (v Var[V]) onLet(s *Spec) {
if v.Before != nil {
s.Before(v.execBefore)
}
v.letDeps(s)
}

func (v Var[V]) execBefore(t *T) {
Expand Down Expand Up @@ -141,6 +164,11 @@ func (v Var[V]) Bind(s *Spec) Var[V] {
return v.Let(s, v.Init)
}

func (v Var[V]) bind(s *Spec) {
s.testingTB.Helper()
_ = v.Bind(s)
}

// EagerLoading allows the variable to be loaded before the action and assertion block is reached.
// This can be useful when you want to have a variable that cause side effect on your system.
// Like it should be present in some sort of attached resource/storage.
Expand Down
117 changes: 117 additions & 0 deletions Var_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package testcase_test

import (
"fmt"
"strconv"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -1090,6 +1091,122 @@ func TestVar_PreviousValue_smoke(t *testing.T) {
})
}

func TestVar_dependencies(t *testing.T) {
t.Run("Var with dependency on variable that has no requirement to be bound can be used with lazy binding", func(t *testing.T) {
v1 := testcase.Var[int]{
ID: "my v1 var",
Init: func(t *testcase.T) int {
return t.Random.Int()
},
}
v2 := testcase.Var[string]{
ID: "my v2 variable",
Init: func(t *testcase.T) string {
return strconv.Itoa(v1.Get(t))
},
Deps: testcase.Vars{
v1,
},
}

dtb := &doubles.TB{}
s := testcase.NewSpec(dtb)
tct := testcase.NewT(dtb, s)

var n int
assert.NotPanic(t, func() { n = v1.Get(tct) })
assert.False(t, dtb.Failed())
assert.Equal(t, strconv.Itoa(n), v2.Get(tct))
})
t.Run("Var with dependency on variable that requires OnLet", func(t *testing.T) {
isV1Initialised := testcase.Var[bool]{
ID: "v1 var init state",
Init: func(t *testcase.T) bool {
return false
},
}
v1 := testcase.Var[int]{
ID: "my v1 var",
Init: func(t *testcase.T) int {
isV1Initialised.Set(t, true)
return t.Random.Int()
},
OnLet: func(s *testcase.Spec, v testcase.Var[int]) {},
}
v2 := testcase.Var[string]{
ID: "my v2 variable",
Init: func(t *testcase.T) string {
return t.Random.String()
},
Deps: testcase.Vars{
v1,
},
}

t.Run("it fails if the dependent variable is not bound to the Spec", func(t *testing.T) {
dtb := &doubles.TB{}
s := testcase.NewSpec(dtb)
tct := testcase.NewT(dtb, s)

assert.Panic(t, func() { v2.Get(tct) })
assert.True(t, dtb.Failed())
})

t.Run("binding the dependent variable is possible through the dependent variable", func(t *testing.T) {
dtb := &doubles.TB{}
s := testcase.NewSpec(dtb)
v2.Bind(s)
tct := testcase.NewT(dtb, s)

assert.NotPanic(t, func() { v2.Get(tct) })
assert.False(t, dtb.Failed())
assert.True(t, isV1Initialised.Get(tct))
})
})

t.Run("nested binding", func(t *testing.T) {
var okV1 bool
v1 := testcase.Var[int]{
ID: "V1",
Init: func(t *testcase.T) int {
okV1 = true
return t.Random.Int()
},
OnLet: func(s *testcase.Spec, v testcase.Var[int]) {
s.HasSideEffect() // this variable has side effect
},
}
v2 := testcase.Var[string]{
ID: "V2",
Init: func(t *testcase.T) string {
return strconv.Itoa(v1.Get(t))
},
Deps: testcase.Vars{
v1,
},
}
v3 := testcase.Var[string]{
ID: "V3",
Init: func(t *testcase.T) string {
return v2.Get(t)
},
Deps: testcase.Vars{
v2,
},
}

dtb := &doubles.TB{}
s := testcase.NewSpec(dtb)
v3.Bind(s)
tct := testcase.NewT(dtb, s)

assert.False(t, okV1)
assert.NotPanic(t, func() { v3.Get(tct) })
assert.False(t, dtb.Failed())
assert.True(t, okV1)
})
}

//func TestCastToVarInit(t *testing.T) {
// s := testcase.NewSpec(t)
//
Expand Down
26 changes: 26 additions & 0 deletions variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func newVariables() *variables {
onLet: make(map[string]struct{}),
locks: make(map[string]*sync.RWMutex),
before: make(map[string]struct{}),
deps: make(map[string]*sync.Once),
}
}

Expand All @@ -30,6 +31,7 @@ type variables struct {
before map[string]struct{}
cache map[string]any
cacheSuper *variablesSuperCache
deps map[string]*sync.Once
}

type variablesInitBlock func(t *T) any
Expand Down Expand Up @@ -188,6 +190,30 @@ func (v *variables) LookupSuper(t *T, varName string) (any, bool) {
v.SetSuper(varName, val)
return val, true
}

func (v *variables) depsInitDo(id string, fn func()) {
v.depsInitFor(id).Do(fn)
}

func (v *variables) depsInitFor(id string) *sync.Once {
//
// FAST
v.mutex.RLock()
once, ok := v.deps[id]
v.mutex.RUnlock()
if ok && once != nil {
return once
}
//
// SLOW
v.mutex.Lock()
defer v.mutex.Unlock()
if _, ok := v.deps[id]; !ok {
v.deps[id] = &sync.Once{}
}
return v.deps[id]
}

func newVariablesSuperCache() *variablesSuperCache {
return &variablesSuperCache{
cache: make(map[string]map[int]any),
Expand Down

0 comments on commit db0b18a

Please sign in to comment.