Skip to content

Commit c0912a4

Browse files
committed
test(flagd): run the conformance suites from make tck, not an env var
`make tck` now runs every test matching "Conformance" and `make e2e` skips exactly those, so these two suites have the step of their own that Appendix F asks for and the TCK_RUN gate has nothing left to do. It is removed rather than kept alongside, because two mechanisms for one exclusion leave the next person to touch the pipeline guessing which is load-bearing. Nothing is lost on the point the gate was defending. The split is a test-name filter and not a build tag, so both targets still compile this file under -tags=e2e and a signature change in tools/tck still cannot rot it unnoticed -- which was the whole reason the gate was a runtime skip in the first place. testing.Short() stays. It is no longer the exclusion; it is the one guard left for a developer who names this package directly, and the README now says why neither it nor an environment variable is sufficient alone: -short defaults the wrong way round and a variable is invisible from the build. What a name filter costs is that a rename can move a suite from one target to the other in silence. conformance_naming_test.go pays that off: it parses this package and fails unless the tests that reach tck.Run are exactly the tests the pattern selects, in both directions, and fails too if it finds no suite at all so that it cannot pass vacuously. It carries no build tag, so `make test` runs it with neither Docker nor -tags=e2e -- a guard that only ran in the build it protects would be worth nothing. Signed-off-by: Simon Schrottner <simon.schrottner@flagsmith.com>
1 parent ac871b1 commit c0912a4

3 files changed

Lines changed: 263 additions & 39 deletions

File tree

providers/flagd/e2e/README.md

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -182,30 +182,57 @@ source has populated the store. The OFREP suite documents the race in full.
182182
effect when it returns, and a suite that sleeps instead of holding it to that promise stops being
183183
able to detect when it breaks. This belongs in the testbed.
184184

185-
**These two suites are excluded from the default build.** They skip unless `TCK_RUN` is set, and a
186-
maintainer runs them by hand before merge:
185+
**These two suites have a step of their own, and it is the one command to run them:**
187186

188187
```bash
189-
TCK_RUN=1 go test -tags=e2e -run TestFlagdRPCConformance -timeout=10m ./...
190-
TCK_RUN=1 go test -tags=e2e -run Conformance -timeout=20m ./...
188+
make tck # from the repository root
189+
go test -tags=e2e -run Conformance -timeout=20m ./... # equivalently, from here
191190
```
192191

193-
Why an adoption suite is excluded rather than gating a merge is the same argument in every language,
194-
and it is settled in Appendix F's
192+
`make tck` is `go test -count=1 -timeout=20m -tags=e2e -run 'Conformance'` over every module in the
193+
workspace. `make e2e` is the same sweep with `-skip 'Conformance'` in place of `-run`, so these two
194+
suites do not run there and no pull request starts a Docker stack for them.
195+
196+
Why an adoption suite is excluded rather than gating a merge, and why it gets a step of its own
197+
rather than a slice of an existing e2e suite, is the same argument in every language and is settled
198+
in Appendix F's
195199
["Running the suite in CI"](https://github.com/open-feature/spec/blob/main/specification/appendix-f-provider-conformance.md#running-the-suite-in-ci)
196-
rather than restated here. The mechanism is Go's, and it is worth stating exactly because the
197-
appendix names two mistakes and this file had made the first of them:
198-
199-
- The gate is a **runtime skip inside the test function**, reading `TCK_RUN`. Nothing in the build
200-
re-enables it. A build tag would not have worked here — `make e2e` expands to a `go test
201-
-tags=e2e` over every module in the workspace, so the tag is applied to everything and both these
202-
suites were in fact running, red, on every pull request before the gate existed.
203-
- Because it is a runtime skip rather than `//go:build e2e && tck`, CI still compiles this file
204-
against `tools/tck` under `-tags=e2e`, which is what the appendix asks for: a suite that has
205-
quietly stopped building against its own harness is worse than one that runs and fails. Only the
206-
container work is skipped, and the skip message names the variable.
207-
- It is written down here and in the harness's own README, which is the appendix's second mistake
208-
avoided.
200+
rather than restated here. Its shape, because it is what decides the mechanism below: a red
201+
`make tck` says *conformance* failed, where the same scenarios inside `make e2e` would only say *a
202+
test* failed — and these two suites do carry failures by design, four of them today, each covered by
203+
a declared deviation above. That is correct output from a conformance run and it would be a
204+
regression in an e2e run.
205+
206+
The mechanism is Go's, and it is worth stating exactly, because the two obvious choices are both
207+
wrong here and this file had made one of them:
208+
209+
- **Not a build tag.** `//go:build e2e && tck` would take this file out of the build, and the
210+
appendix asks for the opposite: the suite must keep *compiling* in the default build even when it
211+
does not run, so a signature change in `tools/tck` cannot rot it unnoticed. A test-name filter
212+
excludes the **run** and keeps the **build**`make e2e` and `make tck` both compile this file
213+
under `-tags=e2e`. A tag would also not have excluded anything on its own: `make e2e` applies
214+
`-tags=e2e` to every module in the workspace, so a tag is not an exclusion in this repository, it
215+
is the opposite, and both these suites were in fact running, red, on every pull request before any
216+
gate existed.
217+
- **Not an environment variable.** These suites used to skip unless `TCK_RUN` was set. That is gone:
218+
the target does the same job without hiding the exclusion inside a test function, and it is the
219+
step the appendix asks for, which an environment variable is not. Keeping both would have left two
220+
mechanisms for one exclusion and the next person to touch the pipeline guessing which is
221+
load-bearing.
222+
- **`testing.Short()` is still checked**, and it is not the exclusion. Neither it nor an environment
223+
variable is sufficient alone, and the reason is the default each one picks: `-short` defaults the
224+
wrong way, since without the flag the suite *runs* and every pipeline would have to remember to opt
225+
out, while a variable defaults to off but is invisible from the build — an exclusion nobody can see
226+
is the appendix's second mistake. What the short-mode skip is now is the one guard left for a
227+
developer who runs `go test -tags=e2e ./providers/flagd/e2e/` by hand, which is a deliberate gap:
228+
naming this package is asking for it.
229+
- **The filter is a naming contract and `conformance_naming_test.go` holds it.** A `-run`/`-skip`
230+
split matches names, so a suite renamed without `Conformance` in it would start running under
231+
`make e2e` and stop running under `make tck`, silently. That test parses this package and fails
232+
unless the tests that reach `tck.Run` are exactly the tests the pattern selects, in both
233+
directions. It carries no build tag, so `make test` runs it with neither Docker nor `-tags=e2e`.
234+
- It is written down here, in the harness's own README and in `CONTRIBUTING.md` next to the two
235+
targets it now sits beside, which is the appendix's second mistake avoided.
209236

210237
## Test Framework Components
211238

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package e2e
2+
3+
// This file has no build tag on purpose. It guards the repository's build
4+
// configuration rather than the provider, it needs neither Docker nor
5+
// -tags=e2e, and it is worth nothing if it only runs in the build it is
6+
// protecting. `make test` runs it.
7+
8+
import (
9+
"go/ast"
10+
"go/parser"
11+
"go/token"
12+
"os"
13+
"path"
14+
"path/filepath"
15+
"sort"
16+
"strconv"
17+
"strings"
18+
"testing"
19+
)
20+
21+
// tckFilter is the test-name pattern the Makefile splits on. `make tck` is
22+
// `go test -run 'Conformance'` and `make e2e` is the same sweep with `-skip`
23+
// in its place, so the two targets are the two halves of one filter and every
24+
// test in this package runs in exactly one of them.
25+
//
26+
// That split is a test-name filter rather than a build tag so that both targets
27+
// keep compiling this package against tools/tck -- a conformance suite that has
28+
// quietly stopped building against its own harness is a worse failure than one
29+
// that runs and fails. The cost of choosing a name filter is that a rename can
30+
// move a suite from one target to the other in silence: `make e2e` would start
31+
// pulling Docker images on every pull request and `make tck` would stop running
32+
// the suite, and neither would say anything. This test is that cost paid off.
33+
const tckFilter = "Conformance"
34+
35+
// tckImportPath is the harness. A test that reaches tck.Run, directly or
36+
// through a helper in this package, is a conformance suite; nothing else in
37+
// this package is one.
38+
const tckImportPath = "github.com/open-feature/go-sdk-contrib/tools/tck"
39+
40+
// TestEveryTckSuiteIsSelectedByTheMakeTargetFilter fails unless the tests that
41+
// run the conformance suite are exactly the tests the Makefile's filter picks
42+
// out -- in both directions. A suite whose name loses the pattern stops being
43+
// run by `make tck` and starts being run by `make e2e`; a test that gains the
44+
// pattern without running the suite ends up in the conformance step, where a
45+
// red result would be reported as a conformance failure it is not.
46+
//
47+
// Note that this test must not itself be named for the pattern, and is not.
48+
func TestEveryTckSuiteIsSelectedByTheMakeTargetFilter(t *testing.T) {
49+
tests, runsSuite, err := scanPackage(".")
50+
if err != nil {
51+
t.Fatalf("reading this package's own sources: %v", err)
52+
}
53+
54+
var suites, selected []string
55+
for _, name := range tests {
56+
if runsSuite[name] {
57+
suites = append(suites, name)
58+
}
59+
if strings.Contains(name, tckFilter) {
60+
selected = append(selected, name)
61+
}
62+
}
63+
sort.Strings(suites)
64+
sort.Strings(selected)
65+
66+
// Without this the whole test passes vacuously the day the scan stops
67+
// recognising a call -- an import alias, a suite moved behind another
68+
// helper -- which is the one way a guard like this fails silently.
69+
if len(suites) == 0 {
70+
t.Fatalf("found no test calling tck.Run in this package; either the conformance suites are "+
71+
"gone or %s can no longer recognise them, and in both cases `make tck` is running nothing",
72+
"conformance_naming_test.go")
73+
}
74+
75+
for _, name := range suites {
76+
if !strings.Contains(name, tckFilter) {
77+
t.Errorf("%s runs the conformance suite but its name does not contain %q, so `make tck` "+
78+
"will not run it and `make e2e` will: rename it", name, tckFilter)
79+
}
80+
}
81+
for _, name := range selected {
82+
if !runsSuite[name] {
83+
t.Errorf("%s does not run the conformance suite but its name contains %q, so `make tck` "+
84+
"will run it and report any failure as a conformance failure: rename it", name, tckFilter)
85+
}
86+
}
87+
88+
t.Logf("conformance suites in this package: %s", strings.Join(suites, ", "))
89+
}
90+
91+
// scanPackage parses every _test.go file in dir and returns the names of its
92+
// test functions, plus the set of package-level functions that reach tck.Run.
93+
// Build tags are irrelevant to it, which is the point: it reads the e2e-tagged
94+
// suites from an untagged test.
95+
func scanPackage(dir string) (tests []string, runsSuite map[string]bool, err error) {
96+
entries, err := os.ReadDir(dir)
97+
if err != nil {
98+
return nil, nil, err
99+
}
100+
101+
fset := token.NewFileSet()
102+
calls := map[string]map[string]bool{}
103+
runsSuite = map[string]bool{}
104+
105+
for _, entry := range entries {
106+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), "_test.go") {
107+
continue
108+
}
109+
file, parseErr := parser.ParseFile(fset, filepath.Join(dir, entry.Name()), nil, 0)
110+
if parseErr != nil {
111+
return nil, nil, parseErr
112+
}
113+
local := localNameOf(file, tckImportPath)
114+
115+
for _, decl := range file.Decls {
116+
fn, ok := decl.(*ast.FuncDecl)
117+
if !ok || fn.Recv != nil || fn.Body == nil {
118+
continue
119+
}
120+
name := fn.Name.Name
121+
if isTestFunc(fn) {
122+
tests = append(tests, name)
123+
}
124+
ast.Inspect(fn.Body, func(node ast.Node) bool {
125+
call, ok := node.(*ast.CallExpr)
126+
if !ok {
127+
return true
128+
}
129+
switch fun := call.Fun.(type) {
130+
case *ast.SelectorExpr:
131+
pkg, ok := fun.X.(*ast.Ident)
132+
if ok && local != "" && pkg.Name == local && fun.Sel.Name == "Run" {
133+
runsSuite[name] = true
134+
}
135+
case *ast.Ident:
136+
if calls[name] == nil {
137+
calls[name] = map[string]bool{}
138+
}
139+
calls[name][fun.Name] = true
140+
}
141+
return true
142+
})
143+
}
144+
}
145+
146+
// A suite is usually reached through a helper -- runConformance here -- so
147+
// follow same-package calls to a fixed point rather than only looking for
148+
// tck.Run in the test function itself.
149+
for changed := true; changed; {
150+
changed = false
151+
for caller, callees := range calls {
152+
if runsSuite[caller] {
153+
continue
154+
}
155+
for callee := range callees {
156+
if runsSuite[callee] {
157+
runsSuite[caller] = true
158+
changed = true
159+
break
160+
}
161+
}
162+
}
163+
}
164+
165+
sort.Strings(tests)
166+
return tests, runsSuite, nil
167+
}
168+
169+
// localNameOf returns the name importPath is bound to in file, honouring an
170+
// alias, or "" if the file does not import it.
171+
func localNameOf(file *ast.File, importPath string) string {
172+
for _, spec := range file.Imports {
173+
unquoted, err := strconv.Unquote(spec.Path.Value)
174+
if err != nil || unquoted != importPath {
175+
continue
176+
}
177+
if spec.Name != nil {
178+
return spec.Name.Name
179+
}
180+
return path.Base(unquoted)
181+
}
182+
return ""
183+
}
184+
185+
// isTestFunc applies the testing package's own rule for what `go test` will
186+
// run, so that this test and the test binary agree on the set of names.
187+
func isTestFunc(fn *ast.FuncDecl) bool {
188+
name := fn.Name.Name
189+
if !strings.HasPrefix(name, "Test") {
190+
return false
191+
}
192+
if rest := name[len("Test"):]; rest != "" && rest[0] >= 'a' && rest[0] <= 'z' {
193+
return false
194+
}
195+
if fn.Type.Params == nil || len(fn.Type.Params.List) != 1 {
196+
return false
197+
}
198+
star, ok := fn.Type.Params.List[0].Type.(*ast.StarExpr)
199+
if !ok {
200+
return false
201+
}
202+
sel, ok := star.X.(*ast.SelectorExpr)
203+
if !ok {
204+
return false
205+
}
206+
pkg, ok := sel.X.(*ast.Ident)
207+
return ok && pkg.Name == "testing" && sel.Sel.Name == "T"
208+
}

providers/flagd/e2e/tck_test.go

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ package e2e
44

55
import (
66
"context"
7-
"os"
87
"testing"
98
"time"
109

@@ -494,30 +493,20 @@ type conformanceSuite struct {
494493
gracePeriod int
495494
}
496495

497-
// runEnv gates the conformance suites out of a default build.
496+
// runConformance is where both suites below come, and the name of the test that
497+
// calls it is load-bearing: `make tck` runs every test matching "Conformance"
498+
// and `make e2e` skips exactly those, so this is what keeps a Docker stack out
499+
// of every pull request while leaving the file compiled — and typechecked
500+
// against tools/tck — in both. There is no environment variable and no second
501+
// build tag; conformance_naming_test.go is what stops a rename from quietly
502+
// swapping which of the two targets these run in.
498503
//
499-
// `make e2e` runs every module's e2e-tagged tests, so without this the two
500-
// suites below would start a Docker stack each on every pull request. They are
501-
// also expected to be red while the fixture gap described in the README stands,
502-
// and a conformance report that records a deviation plus a CI job that fails on
503-
// it are two answers to the same question. The policy is therefore exclusion,
504-
// with a maintainer running these by hand before merge, and this is where it is
505-
// enforced rather than merely described.
506-
//
507-
// A runtime skip rather than a second build tag on purpose: the adoption stays
508-
// compiled under -tags=e2e, so CI still typechecks it against tools/tck and a
509-
// signature change there cannot rot this file unnoticed. Only the container
510-
// work is skipped, and the skip names the variable that turns it on.
511-
const runEnv = "TCK_RUN"
512-
504+
// The short-mode skip below is not that exclusion. It is the one guard left for
505+
// someone who names this package directly, and it stays for that.
513506
func runConformance(t *testing.T, suite conformanceSuite) {
514507
if testing.Short() {
515508
t.Skip("skipping e2e tests in short mode")
516509
}
517-
if os.Getenv(runEnv) == "" {
518-
t.Skipf("the provider conformance suite is excluded from the default build: set %s=1 to "+
519-
"run it (it needs Docker and takes minutes). See README.md", runEnv)
520-
}
521510

522511
tck.Run(t,
523512
tck.WithName(suite.name),

0 commit comments

Comments
 (0)