Skip to content

Commit 37f74c5

Browse files
authored
add test sandboxing tool (#5)
1 parent 28a0434 commit 37f74c5

File tree

7 files changed

+581
-1
lines changed

7 files changed

+581
-1
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Subpackage `httphelpers` provides convenience wrappers for using `net/http` and
1414

1515
Subpackage `ldservices` is specifically for testing LaunchDarkly SDK client components; it provides HTTP handlers that simulate the service endpoints used by the SDK.
1616

17+
Subpackage `testbox` provides the ability to write tests-of-tests within the Go testing framework.
18+
1719
## Usage
1820

1921
Import any of these packages in your test code:
@@ -23,10 +25,11 @@ import (
2325
"github.com/launchdarkly/go-test-helpers"
2426
"github.com/launchdarkly/go-test-helpers/httphelpers"
2527
"github.com/launchdarkly/go-test-helpers/ldservices"
28+
"github.com/launchdarkly/go-test-helpers/testbox"
2629
)
2730
```
2831

29-
Breaking changes will only be made in a new major version. It is advisable to use a dependency manager to pin these dependencies to a major version branch (`v1`, etc.).
32+
Breaking changes will only be made in a new major version. It is advisable to use a dependency manager to pin these dependencies to a module version or a major version branch.
3033

3134
## Contributing
3235

testbox/interface.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package testbox
2+
3+
import "github.com/stretchr/testify/require"
4+
5+
// TestingT is a subset of the testing.T interface that allows tests to run in either a real test
6+
// context, or a mock test scope that is decoupled from the regular testing framework (SandboxTest).
7+
//
8+
// This may be useful in a scenario where you have a contract test that verifies the behavior of some
9+
// unknown interface implementation. In order to verify that the contract test is reliable, you could
10+
// create implementations that either adhere to the contract or deliberately break it, run the contract
11+
// test against those, and verify that the test fails if and only if it should fail.
12+
//
13+
// The reason this cannot be done with the Go testing package alone is that the standard testing.T type
14+
// cannot be created from within test code; instances are always passed in from the test framework.
15+
// Therefore, the contract test would have to be run against the actual *testing.T instance that
16+
// belongs to the test-of-the-test, and if it failed in a situation when we actually wanted it to
17+
// to fail, that would be incorrectly reported as a failure of the test-of-the-test.
18+
//
19+
// To work around this limitation of the testing package, this package provides a TestingT interface
20+
// that has two implementations: real and mock. Test logic can then be written against this TestingT,
21+
// rather than *testing.T.
22+
//
23+
// func RunContractTests(t *testing.T, impl InterfaceUnderTest) {
24+
// runContractTests(testbox.RealTest(t))
25+
// }
26+
//
27+
// func runContractTests(abstractT testbox.TestingT, impl InterfaceUnderTest) {
28+
// assert.True(abstractT, impl.SomeConditionThatShouldBeTrueForTheseInputs(someParams))
29+
// abstractT.Run("subtest", func(abstractSubT helpers.TestingT) { ... }
30+
// }
31+
//
32+
// func TestContractTestFailureCondition(t *testing.T) {
33+
// impl := createDeliberatelyBrokenImplementation()
34+
// result := testbox.SandboxTest(func(abstractT testbox.TestingT) {
35+
// runContractTests(abstractT, impl) })
36+
// assert.True(t, result.Failed // we expect it to fail
37+
// assert.Len(t, result.Failures, 1)
38+
// }
39+
//
40+
// TestingT includes the same subsets of testing.T methods that are defined in the TestingT interfaces
41+
// of github.com/stretchr/testify/assert and github.com/stretchr/testify/require, so all assertions in
42+
// those packages will work. It also provides Run, Skip, and SkipNow. It does not support Parallel.
43+
type TestingT interface {
44+
require.TestingT
45+
// Run runs a subtest with a new TestingT that applies only to the scope of the subtest. It is
46+
// equivalent to the same method in testing.T, except the subtest takes a parameter of type TestingT
47+
// instead of *testing.T.
48+
//
49+
// If the subtest fails, the parent test also fails, but FailNow and SkipNow on the subtest do not
50+
// cause the parent test to exit early.
51+
Run(name string, action func(TestingT))
52+
53+
// Failed tells whether whether any assertions in the test have failed so far. It is equivalent to
54+
// the same method in testing.T.
55+
Failed() bool
56+
57+
// Skip marks the test as skipped and exits early, logging a message. It is equivalent to the same
58+
// method in testing.T.
59+
Skip(args ...interface{})
60+
61+
// SkipNow marks the test as skipped and exits early. It is equivalent to the same method in testing.T.
62+
SkipNow()
63+
}

testbox/package_info.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Package testbox provides the ability to run test logic that uses a subset of Go's testing.T
2+
// methods either inside or outside the regular testing environment.
3+
package testbox

testbox/real.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package testbox
2+
3+
import "testing"
4+
5+
type realTestingT struct {
6+
t *testing.T
7+
}
8+
9+
// RealTest provides an implementation of TestingT for running test logic in a regular test context.
10+
//
11+
// See TestingT for details.
12+
func RealTest(t *testing.T) TestingT {
13+
return realTestingT{t}
14+
}
15+
16+
func (r realTestingT) Errorf(format string, args ...interface{}) {
17+
r.t.Errorf(format, args...) // COVERAGE: can't do this in test_sandbox_test; it'll cause a real failure
18+
}
19+
20+
func (r realTestingT) Run(name string, action func(TestingT)) {
21+
r.t.Run(name, func(tt *testing.T) { action(realTestingT{tt}) })
22+
}
23+
24+
func (r realTestingT) FailNow() {
25+
r.t.FailNow() // COVERAGE: can't do this in test_sandbox_test; it'll cause a real failure
26+
}
27+
28+
func (r realTestingT) Failed() bool {
29+
return r.t.Failed()
30+
}
31+
32+
func (r realTestingT) Skip(args ...interface{}) {
33+
r.t.Skip(args...)
34+
}
35+
36+
func (r realTestingT) SkipNow() {
37+
r.t.SkipNow()
38+
}

testbox/real_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package testbox
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestRealTest(t *testing.T) {
10+
// can't test failure cases, since then *this* test would fail
11+
12+
t.Run("success", func(t *testing.T) {
13+
rt := RealTest(t)
14+
assert.True(rt, true)
15+
16+
assert.False(t, rt.Failed())
17+
assert.False(t, t.Failed())
18+
assert.False(t, t.Skipped())
19+
})
20+
21+
t.Run("subtest success", func(t *testing.T) {
22+
ran := false
23+
24+
rt := RealTest(t)
25+
rt.Run("sub", func(u TestingT) {
26+
ran = true
27+
assert.True(u, true)
28+
})
29+
30+
assert.True(t, ran)
31+
32+
assert.False(t, rt.Failed())
33+
assert.False(t, t.Failed())
34+
assert.False(t, t.Skipped())
35+
})
36+
37+
t.Run("skip", func(t *testing.T) { // this test will always be reported as skipped
38+
rt := RealTest(t)
39+
rt.Skip()
40+
41+
assert.True(t, false) // won't execute because we exited early on Skip
42+
})
43+
44+
t.Run("subtest skip", func(t *testing.T) {
45+
ran := false
46+
continued := false
47+
48+
rt := RealTest(t)
49+
rt.Run("sub", func(u TestingT) {
50+
ran = true
51+
u.Skip("let's skip this")
52+
continued = true
53+
})
54+
55+
assert.True(t, ran)
56+
assert.False(t, continued)
57+
58+
assert.False(t, rt.Failed())
59+
assert.False(t, t.Failed())
60+
assert.False(t, t.Skipped())
61+
})
62+
63+
t.Run("subtest SkipNow", func(t *testing.T) {
64+
ran := false
65+
continued := false
66+
67+
rt := RealTest(t)
68+
rt.Run("sub", func(u TestingT) {
69+
ran = true
70+
u.SkipNow()
71+
continued = true
72+
})
73+
74+
assert.True(t, ran)
75+
assert.False(t, continued)
76+
77+
assert.False(t, rt.Failed())
78+
assert.False(t, t.Failed())
79+
assert.False(t, t.Skipped())
80+
})
81+
}

testbox/sandbox.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package testbox
2+
3+
import (
4+
"fmt"
5+
"runtime"
6+
"strings"
7+
"sync"
8+
)
9+
10+
// SandboxResult describes the aggregate test state produced by calling SandboxTest.
11+
type SandboxResult struct {
12+
// True if any failures were reported during SandboxTest.
13+
Failed bool
14+
15+
// True if the test run with SandboxTest called Skip or SkipNow. This is only true if
16+
// the top-level TestingT was skipped, not any subtests.
17+
Skipped bool
18+
19+
// All failures logged during SandboxTest, including subtests.
20+
Failures []LogItem
21+
22+
// All tests that were skipped during SandboxTest, including subtests.
23+
Skips []LogItem
24+
}
25+
26+
type testState struct {
27+
failed bool
28+
skipped bool
29+
failures []LogItem
30+
skips []LogItem
31+
}
32+
33+
// TestPath identifies the level of test that failed or skipped. SandboxResult.Failures and
34+
// SandboxResult.Skips use this type to distinguish between the top-level test that was run with
35+
// SandboxTest and subtests that were run within that test with TestingT.Run(). A nil value means the
36+
// top-level test; a single string element is the name of a subtest run from the top level with
37+
// TestingT.Run(); nested subtests add an element for each level.
38+
type TestPath []string
39+
40+
// LogItem describes either a failed assertion or a skip that happened during SandboxTest.
41+
type LogItem struct {
42+
// Path identifies the level of test that failed or was skipped.
43+
Path TestPath
44+
45+
// Message is the failure message or skip message, if any. It is the result of calling fmt.Sprintf
46+
// or Sprintln on the arguments that were passed to TestingT.Errorf or TestingT.Skip. If a test
47+
// failed without specifying a message, this is "".
48+
Message string
49+
}
50+
51+
type mockTestingT struct {
52+
testState
53+
path TestPath
54+
lock sync.Mutex
55+
}
56+
57+
// SandboxTest runs a test function against a TestingT instance that applies only to the scope of
58+
// that test. If the function makes a failed assertion, marks the test as skipped, or forces an early
59+
// exit with FailNow or SkipNow, this is reflected in the SandboxResult but does not affect the state
60+
// of the regular test framework (assuming that this code is even executing within a Go test; it does
61+
// not have to be).
62+
//
63+
// The reason this uses a callback function parameter, rather than simply having the SandboxResult
64+
// implement TestingT itself, is that the function must be run on a separate goroutine so that
65+
// the sandbox can intercept any early exits from FailNow or SkipNow.
66+
//
67+
// SandboxTest does not recover from panics.
68+
//
69+
// See TestingT for more details.
70+
func SandboxTest(action func(TestingT)) SandboxResult {
71+
sub := new(mockTestingT)
72+
sub.runSafely(action)
73+
state := sub.getState()
74+
return SandboxResult{
75+
Failed: state.failed,
76+
Skipped: state.skipped,
77+
Failures: state.failures,
78+
Skips: state.skips,
79+
}
80+
}
81+
82+
func (m *mockTestingT) Errorf(format string, args ...interface{}) {
83+
m.lock.Lock()
84+
defer m.lock.Unlock()
85+
m.failed = true
86+
m.failures = append(m.failures, LogItem{Path: m.path, Message: fmt.Sprintf(format, args...)})
87+
}
88+
89+
func (m *mockTestingT) Run(name string, action func(TestingT)) {
90+
sub := &mockTestingT{path: append(m.path, name)}
91+
sub.runSafely(action)
92+
subState := sub.getState()
93+
94+
m.lock.Lock()
95+
defer m.lock.Unlock()
96+
m.failed = m.failed || subState.failed
97+
m.failures = append(m.failures, subState.failures...)
98+
m.skips = append(m.skips, subState.skips...)
99+
}
100+
101+
func (m *mockTestingT) FailNow() {
102+
m.lock.Lock()
103+
defer m.lock.Unlock()
104+
m.testState.failed = true
105+
runtime.Goexit()
106+
}
107+
108+
func (m *mockTestingT) Failed() bool {
109+
m.lock.Lock()
110+
defer m.lock.Unlock()
111+
return m.failed
112+
}
113+
114+
func (m *mockTestingT) Skip(args ...interface{}) {
115+
m.lock.Lock()
116+
defer m.lock.Unlock()
117+
m.skipped = true
118+
m.skips = append(m.skips, LogItem{Path: m.path, Message: strings.TrimSuffix(fmt.Sprintln(args...), "\n")})
119+
runtime.Goexit()
120+
}
121+
122+
func (m *mockTestingT) SkipNow() {
123+
m.Skip()
124+
}
125+
126+
func (m *mockTestingT) getState() testState {
127+
m.lock.Lock()
128+
defer m.lock.Unlock()
129+
ret := testState{failed: m.failed, skipped: m.skipped}
130+
if len(m.failures) > 0 {
131+
ret.failures = make([]LogItem, len(m.failures))
132+
copy(ret.failures, m.failures)
133+
}
134+
if len(m.skips) > 0 {
135+
ret.skips = make([]LogItem, len(m.skips))
136+
copy(ret.skips, m.skips)
137+
}
138+
return ret
139+
}
140+
141+
func (m *mockTestingT) runSafely(action func(TestingT)) {
142+
exited := make(chan struct{}, 1)
143+
go func() {
144+
defer func() {
145+
close(exited)
146+
}()
147+
action(m)
148+
}()
149+
<-exited
150+
}

0 commit comments

Comments
 (0)