-
Notifications
You must be signed in to change notification settings - Fork 0
/
expect.go
358 lines (333 loc) · 10.5 KB
/
expect.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
// Package efftesting checks expectations and optionally rewrites them if the EFFTESTING_UPDATE=1 envvar is set.
//
// Its main feature is an Expect(effectName string, want any, got string) function.
// It stringifies want and compares that string to got and fails the test if they are not equal.
// The magic is this: if got is wrong, efftesting can automatically update the source code to the new value.
// It should make updating the tests for a code's effects a bit easier.
// effectName is just an arbitrary name to make the test log messages clearer.
//
// See https://github.com/ypsu/efftesting/tree/main/example/example_test.go for a full example.
// See pkgtrim_test.go in https://github.com/ypsu/pkgtrim for a more realistic example.
//
// Example:
//
// func MyStringLength(s string) int {
// return len(s)
// }
//
// func TestLength(t *testing.T) {
// et := efftesting.New(t)
// et.Expect("string length of tükör", MyStringLength("tükör"), "7")
// }
//
// func TestMain(m *testing.M) {
// os.Exit(efftesting.Main(m))
// }
//
// Suppose you change the string to count utf8 characters instead of bytes:
//
// func MyStringLength(s string) int {
// return utf8.RuneCountInString(s)
// }
//
// The expectation now fails with this:
//
// $ go test example_test.go
// --- FAIL: TestLength (0.00s)
//
// example_test.go:17: Non-empty diff for effect "string length of tükör", diff (-want, +got):
// -7
// +5
//
// FAIL
// Expectations need updating, use `EFFTESTING_UPDATE=1 go test ./...` for that.
//
// Rerun the test with the EFFTESTING_UPDATE=1 envvar to update the test expectation to expect 5 if that was expected from the change.
//
// There's also a Check(effectName string, want any, got string) variant that quits the test if the expectation doesn't match.
// So instead of this:
//
// ...
// foo, err = foolib.New()
// if err != nil {
// t.Failf("foolib.New() failed: %v.", err)
// }
// ...
//
// it's possible to write this:
//
// foo, err = foolib.New()
// et.Check("foolib.New() succeeded", err, "null")
//
// You don't need to know beforehand that err's value will be stringified to null.
// Initially add only et.Check("foolib.New() succeeded", err, "") and then simply run update expectation command as described above.
// In fact you no longer need to know beforehand what any expression's result will be, you only need to tell if a result is correct or not.
// Ideal for code whose result is more a matter of taste rather than correctness (e.g. markdown rendering).
//
// Most typical expectations can be rewritten to efftesting expectations.
// E.g. a EXPECT_LESS_THAN(3, 4) can be rewritten to Expect("comparison", 3 < 4, "true").
// Or EXPECT_STRING_CONTAINS(str, "foo") can be rewritten to Expect("contains foo", strings.Contains(str, "foo"), "true").
// Expect and Check can be a powerful building block to make managing tests simpler.
//
// efftesting formats multiline strings with backticks.
// For convenience it formats structs and slices into a multiline json:
//
// et.Expect("slice example", strings.Split("This is a sentence.", " "), `
// [
// "This",
// "is",
// "a",
// "sentence."
// ]`)
//
// Tip: include the string "TODO" in effectName for expectations that are still under development and are thus incorrect.
// This allows committing the expectations first.
// Once the correct implementation is in, the tests can be quickly updated with a single command.
// The only additional work then needed is removing the TODO markers while verifying the correctness of the expectations.
// Makes a test driven development much easier.
package efftesting
import (
"bytes"
"encoding/json"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"os"
"runtime"
"strings"
"sync"
"testing"
)
// expectationString is a local type so that users cannot create it.
// Makes the library harder to misuse because users cannot pass in variables.
// This string must always be a string constant passed into the function due to the auto-rewrite feature.
type expectationString string
var updatemode bool
// ET (EffTesting) is an expectation tester.
type ET struct {
t *testing.T
}
// New creates a new ET.
func New(t *testing.T) ET {
return ET{t}
}
// detab removes the leading tab characters from the string if it's a multiline string.
// That's because efftesting uses backticks for multiline strings and tab indents them.
func detab(s string) string {
if !strings.HasPrefix(s, "\n") {
return s
}
indent := 1
for indent < len(s) && s[indent] == '\t' {
indent++
}
return strings.TrimPrefix(strings.TrimRight(strings.ReplaceAll(s, s[:indent], "\n"), "\t"), "\n")
}
// Expect checks that want is got.
// want must be a string literal otherwise the update feature won't work.
func (et ET) Expect(desc string, got any, want expectationString) {
g, w := stringify(got), detab(string(want))
if g == w {
return
}
const format = "Non-empty diff for effect \"%s\", diff (-want, +got):\n%s"
diff := Diff(w, g)
defaultReplacer.replace(g)
et.t.Helper()
et.t.Errorf(format, desc, diff)
}
// Check checks that want is got.
// If they are unequal, the test is aborted.
// want must be a string literal otherwise the update feature won't work.
func (et ET) Check(desc string, got any, want expectationString) {
g, w := stringify(got), detab(string(want))
if g == w {
return
}
const format = "Non-empty diff for effect \"%s\", diff (-want, +got):\n%s"
diff := Diff(w, g)
defaultReplacer.replace(g)
et.t.Helper()
if updatemode {
et.t.Errorf(format, desc, diff)
} else {
et.t.Fatalf(format, desc, diff)
}
}
// Context is the number of lines to display before and after the diff starts and ends.
var Context = 2
// Diff is the function to diff the expectation against the got value.
// Defaults to a very simple diff treats all lines changed from the first until the last change.
var Diff = dummydiff
func dummydiff(lts, rts string) string {
if lts == rts {
return ""
}
lt, rt := strings.Split(lts, "\n"), strings.Split(rts, "\n")
minlen := min(len(lt), len(rt))
var commonStart, commonEnd int
for commonStart < minlen && lt[commonStart] == rt[commonStart] {
commonStart++
}
for commonEnd < minlen && lt[len(lt)-1-commonEnd] == rt[len(rt)-1-commonEnd] {
commonEnd++
}
d := make([]string, 0, 2*Context+len(lt)-commonStart-commonEnd+len(rt)-commonStart-commonEnd)
for i := max(0, commonStart-Context); i < commonStart; i++ {
d = append(d, " "+lt[i])
}
for i := commonStart; i < len(lt)-commonEnd; i++ {
d = append(d, "-"+lt[i])
}
for i := commonStart; i < len(rt)-commonEnd; i++ {
d = append(d, "+"+rt[i])
}
for i := len(lt) - commonEnd; i < min(len(lt), len(lt)-commonEnd+Context); i++ {
d = append(d, " "+lt[i])
}
return strings.Join(d, "\n") + "\n"
}
var defaultReplacer = replacer{
replacements: map[location]string{},
}
type location struct {
fname string
line int
}
func (loc location) String() string {
return fmt.Sprintf("%s:%d", loc.fname, loc.line)
}
type replacer struct {
mu sync.Mutex
replacements map[location]string
}
func (r *replacer) replace(newstr string) bool {
loc := location{}
_, loc.fname, loc.line, _ = runtime.Caller(2)
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.replacements[loc]; ok {
return false
}
r.replacements[loc] = newstr
return true
}
func (r *replacer) apply(fname string) error {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.replacements) == 0 {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, fname, nil, parser.ParseComments)
if err != nil {
return fmt.Errorf("efftesting/parse source: %w", err)
}
var inspectErr error
ast.Inspect(f, func(n ast.Node) bool {
if inspectErr != nil {
return false
}
if n == nil {
return true
}
// Find the Expect and Check functions that have a pending replacement.
callexpr, ok := n.(*ast.CallExpr)
if !ok || len(callexpr.Args) != 3 {
return true
}
selexpr, ok := callexpr.Fun.(*ast.SelectorExpr)
if !ok || selexpr.Sel.Name != "Expect" && selexpr.Sel.Name != "Check" {
return true
}
pos := fset.Position(callexpr.Pos())
loc := location{pos.Filename, pos.Line}
repl, ok := r.replacements[loc]
if !ok {
return false
}
lit, ok := callexpr.Args[2].(*ast.BasicLit)
if !ok {
inspectErr = fmt.Errorf("%s: expectation is %T, want literal string", pos, callexpr.Args[2])
return false
}
// Replace the expectation with a string wrapped in " or ` quotes, whichever fits best.
delete(r.replacements, loc)
if strings.IndexByte(repl, '\n') == -1 || strings.IndexByte(repl, '`') != -1 {
lit.Value = fmt.Sprintf("%q", repl)
return false
}
indent := strings.Repeat("\t", pos.Column)
ss := strings.Split(repl, "\n")
for i, line := range ss {
if i == 0 || line != "" {
ss[i] = indent + line
}
}
if ss[len(ss)-1] == "" {
// The last line should have one indent less so that the closing `) looks nicely indented.
ss[len(ss)-1] = strings.TrimSuffix(indent, "\t")
}
lit.Value = fmt.Sprintf("`\n%s`", strings.Join(ss, "\n"))
return false
})
if inspectErr != nil {
return fmt.Errorf("efftesting/rewrite %s: %v", fname, inspectErr)
}
bs := &bytes.Buffer{}
if err := format.Node(bs, fset, f); err != nil {
return fmt.Errorf("efftesting/format the updated %s: %v", fname, err)
}
if err := os.WriteFile(fname, bs.Bytes(), 0644); err != nil {
return fmt.Errorf("efftesting/write rewritten source: %v", err)
}
return nil
}
// Main is the TestMain for efftesting.
// If a _test.go file has efftesting expectations then call this explicitly:
//
// func TestMain(m *testing.M) {
// os.Exit(efftesting.Main(m))
// }
func Main(m *testing.M) int {
updatemode = os.Getenv("EFFTESTING_UPDATE") == "1"
code := m.Run()
if code == 0 || !updatemode {
if len(defaultReplacer.replacements) != 0 {
fmt.Fprintf(os.Stderr, "Expectations need updating, use `EFFTESTING_UPDATE=1 go test ./...` for that.\n")
}
return code
}
if len(defaultReplacer.replacements) != 0 {
_, testfile, _, _ := runtime.Caller(1)
if err := defaultReplacer.apply(testfile); err != nil {
fmt.Fprintf(os.Stderr, "efftesting update failed: %v.\n", err)
return 1
}
fmt.Fprintf(os.Stderr, "Expectations updated.\n")
}
return code
}
func stringify(v any) string {
if s, ok := v.(fmt.Stringer); ok {
return s.String()
}
if s, ok := v.(error); ok {
return s.Error()
}
switch v := v.(type) {
case []byte:
return string(v)
case string:
return v
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return fmt.Sprint(v)
}
js, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err.Error()
}
return string(js)
}