diff --git a/T.go b/T.go index f3d8e0c..60f4a71 100644 --- a/T.go +++ b/T.go @@ -2,6 +2,7 @@ package testcase import ( "math/rand" + "sync" "testing" "time" @@ -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 @@ -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. diff --git a/Var.go b/Var.go index da51072..dc2fb7a 100644 --- a/Var.go +++ b/Var.go @@ -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 { @@ -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) }) @@ -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 { @@ -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) { @@ -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. diff --git a/Var_test.go b/Var_test.go index 3f89460..0a21aa3 100644 --- a/Var_test.go +++ b/Var_test.go @@ -2,6 +2,7 @@ package testcase_test import ( "fmt" + "strconv" "sync" "testing" "time" @@ -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) // diff --git a/variables.go b/variables.go index 24fc7ee..7b76efd 100644 --- a/variables.go +++ b/variables.go @@ -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), } } @@ -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 @@ -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),