1
2
3
4
5 package frameworktestutil
6
7 import (
8 "bytes"
9 "os"
10 "path/filepath"
11 "regexp"
12 "strings"
13 "testing"
14
15 "github.com/spf13/cobra"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18 "sigs.k8s.io/kustomize/kyaml/fn/framework"
19 "sigs.k8s.io/kustomize/kyaml/kio"
20 )
21
22 const (
23 DefaultTestDataDirectory = "testdata"
24 DefaultConfigInputFilename = "config.yaml"
25 DefaultInputFilename = "input.yaml"
26 DefaultInputFilenameGlob = "input*.yaml"
27 DefaultOutputFilename = "expected.yaml"
28 DefaultErrorFilename = "errors.txt"
29 )
30
31
32
33 type CommandResultsChecker struct {
34
35
36
37
38 TestDataDirectory string
39
40
41
42
43 ExpectedOutputFilename string
44
45
46
47
48
49 ExpectedErrorFilename string
50
51
52
53 UpdateExpectedFromActual bool
54
55
56
57
58 OutputAssertionFunc AssertionFunc
59
60
61
62
63 ErrorAssertionFunc AssertionFunc
64
65
66
67
68 ConfigInputFilename string
69
70
71
72 InputFilenameGlob string
73
74
75 Command func() *cobra.Command
76
77 *checkerCore
78 }
79
80
81
82 func (rc *CommandResultsChecker) Assert(t *testing.T) bool {
83 t.Helper()
84 if rc.ConfigInputFilename == "" {
85 rc.ConfigInputFilename = DefaultConfigInputFilename
86 }
87 if rc.InputFilenameGlob == "" {
88 rc.InputFilenameGlob = DefaultInputFilenameGlob
89 }
90 rc.checkerCore = &checkerCore{
91 testDataDirectory: rc.TestDataDirectory,
92 expectedOutputFilename: rc.ExpectedOutputFilename,
93 expectedErrorFilename: rc.ExpectedErrorFilename,
94 updateExpectedFromActual: rc.UpdateExpectedFromActual,
95 outputAssertionFunc: rc.OutputAssertionFunc,
96 errorAssertionFunc: rc.ErrorAssertionFunc,
97 }
98 rc.checkerCore.setDefaults()
99 runAllTestCases(t, rc)
100 return true
101 }
102
103 func (rc *CommandResultsChecker) isTestDir(path string) bool {
104 return atLeastOneFileExists(
105 filepath.Join(path, rc.ConfigInputFilename),
106 filepath.Join(path, rc.checkerCore.expectedOutputFilename),
107 filepath.Join(path, rc.checkerCore.expectedErrorFilename),
108 )
109 }
110
111 func (rc *CommandResultsChecker) runInCurrentDir(t *testing.T) (string, string) {
112 t.Helper()
113 _, err := os.Stat(rc.ConfigInputFilename)
114 if os.IsNotExist(err) {
115 t.Errorf("Test case is missing FunctionConfig input file (default: %s)", DefaultConfigInputFilename)
116 }
117 require.NoError(t, err)
118 args := []string{rc.ConfigInputFilename}
119
120 if rc.InputFilenameGlob != "" {
121 inputs, err := filepath.Glob(rc.InputFilenameGlob)
122 require.NoError(t, err)
123 args = append(args, inputs...)
124 }
125
126 var stdOut, stdErr bytes.Buffer
127 cmd := rc.Command()
128 cmd.SetArgs(args)
129 cmd.SetOut(&stdOut)
130 cmd.SetErr(&stdErr)
131
132 _ = cmd.Execute()
133 return stdOut.String(), stdErr.String()
134 }
135
136
137
138 type ProcessorResultsChecker struct {
139
140
141
142
143 TestDataDirectory string
144
145
146
147
148 ExpectedOutputFilename string
149
150
151
152
153
154 ExpectedErrorFilename string
155
156
157
158 UpdateExpectedFromActual bool
159
160
161
162
163 InputFilename string
164
165
166
167
168 OutputAssertionFunc AssertionFunc
169
170
171
172
173 ErrorAssertionFunc AssertionFunc
174
175
176 Processor func() framework.ResourceListProcessor
177
178 *checkerCore
179 }
180
181
182
183 func (rc *ProcessorResultsChecker) Assert(t *testing.T) bool {
184 t.Helper()
185 if rc.InputFilename == "" {
186 rc.InputFilename = DefaultInputFilename
187 }
188 rc.checkerCore = &checkerCore{
189 testDataDirectory: rc.TestDataDirectory,
190 expectedOutputFilename: rc.ExpectedOutputFilename,
191 expectedErrorFilename: rc.ExpectedErrorFilename,
192 updateExpectedFromActual: rc.UpdateExpectedFromActual,
193 outputAssertionFunc: rc.OutputAssertionFunc,
194 errorAssertionFunc: rc.ErrorAssertionFunc,
195 }
196 rc.checkerCore.setDefaults()
197 runAllTestCases(t, rc)
198 return true
199 }
200
201 func (rc *ProcessorResultsChecker) isTestDir(path string) bool {
202 return atLeastOneFileExists(
203 filepath.Join(path, rc.InputFilename),
204 filepath.Join(path, rc.checkerCore.expectedOutputFilename),
205 filepath.Join(path, rc.checkerCore.expectedErrorFilename),
206 )
207 }
208
209 func (rc *ProcessorResultsChecker) runInCurrentDir(t *testing.T) (string, string) {
210 t.Helper()
211 _, err := os.Stat(rc.InputFilename)
212 if os.IsNotExist(err) {
213 t.Errorf("Test case is missing input file (default: %s)", DefaultInputFilename)
214 }
215 require.NoError(t, err)
216
217 actualOutput := bytes.NewBuffer([]byte{})
218 rlBytes, err := os.ReadFile(rc.InputFilename)
219 require.NoError(t, err)
220
221 rw := kio.ByteReadWriter{
222 Reader: bytes.NewBuffer(rlBytes),
223 Writer: actualOutput,
224 }
225 err = framework.Execute(rc.Processor(), &rw)
226 if err != nil {
227 require.NotEmptyf(t, err.Error(), "processor returned error with empty message")
228 return actualOutput.String(), err.Error()
229 }
230 return actualOutput.String(), ""
231 }
232
233 type AssertionFunc func(t *testing.T, expected string, actual string)
234
235
236
237 func RequireEachLineMatches(t *testing.T, expected, actual string) {
238 t.Helper()
239
240 actual = standardizeSpacing(actual)
241 for _, msg := range strings.Split(standardizeSpacing(expected), "\n") {
242 require.Regexp(t, regexp.MustCompile(msg), actual)
243 }
244 }
245
246
247
248 func RequireStrippedStringsEqual(t *testing.T, expected, actual string) {
249 t.Helper()
250 require.Equal(t,
251 standardizeSpacing(expected),
252 standardizeSpacing(actual))
253 }
254
255 func standardizeSpacing(s string) string {
256
257 return strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n")
258 }
259
260
261 type resultsChecker interface {
262
263 TestCasesRun() (paths []string)
264
265
266 rootDir() string
267
268 isTestDir(dir string) bool
269
270 runInCurrentDir(t *testing.T) (stdOut, stdErr string)
271
272 resetTestCasesRun()
273
274 recordTestCase(path string)
275
276 readAssertionFiles(t *testing.T) (expectedOutput string, expectedError string)
277
278 shouldUpdateFixtures() bool
279
280 updateFixtures(t *testing.T, actualOutput string, actualError string)
281
282 assertOutputMatches(t *testing.T, expected string, actual string)
283
284 assertErrorMatches(t *testing.T, expected string, actual string)
285 }
286
287
288 type checkerCore struct {
289 testDataDirectory string
290 expectedOutputFilename string
291 expectedErrorFilename string
292 updateExpectedFromActual bool
293 outputAssertionFunc AssertionFunc
294 errorAssertionFunc AssertionFunc
295
296 testsCasesRun []string
297 }
298
299 func (rc *checkerCore) setDefaults() {
300 if rc.testDataDirectory == "" {
301 rc.testDataDirectory = DefaultTestDataDirectory
302 }
303 if rc.expectedOutputFilename == "" {
304 rc.expectedOutputFilename = DefaultOutputFilename
305 }
306 if rc.expectedErrorFilename == "" {
307 rc.expectedErrorFilename = DefaultErrorFilename
308 }
309 if rc.outputAssertionFunc == nil {
310 rc.outputAssertionFunc = RequireStrippedStringsEqual
311 }
312 if rc.errorAssertionFunc == nil {
313 rc.errorAssertionFunc = RequireEachLineMatches
314 }
315 }
316
317 func (rc *checkerCore) rootDir() string {
318 return rc.testDataDirectory
319 }
320
321 func (rc *checkerCore) TestCasesRun() []string {
322 return rc.testsCasesRun
323 }
324
325 func (rc *checkerCore) resetTestCasesRun() {
326 rc.testsCasesRun = []string{}
327 }
328
329 func (rc *checkerCore) recordTestCase(s string) {
330 rc.testsCasesRun = append(rc.testsCasesRun, s)
331 }
332
333 func (rc *checkerCore) shouldUpdateFixtures() bool {
334 return rc.updateExpectedFromActual
335 }
336
337 func (rc *checkerCore) updateFixtures(t *testing.T, actualOutput string, actualError string) {
338 t.Helper()
339 if actualError != "" {
340 require.NoError(t, os.WriteFile(rc.expectedErrorFilename, []byte(actualError), 0600))
341 }
342 if len(actualOutput) > 0 {
343 require.NoError(t, os.WriteFile(rc.expectedOutputFilename, []byte(actualOutput), 0600))
344 }
345 t.Skip("Updated fixtures for test case")
346 }
347
348 func (rc *checkerCore) assertOutputMatches(t *testing.T, expected string, actual string) {
349 t.Helper()
350 rc.outputAssertionFunc(t, expected, actual)
351 }
352
353 func (rc *checkerCore) assertErrorMatches(t *testing.T, expected string, actual string) {
354 t.Helper()
355 rc.errorAssertionFunc(t, expected, actual)
356 }
357
358 func (rc *checkerCore) readAssertionFiles(t *testing.T) (string, string) {
359 t.Helper()
360
361 var expectedOutput, expectedError string
362 if rc.expectedOutputFilename != "" {
363 _, err := os.Stat(rc.expectedOutputFilename)
364 if !os.IsNotExist(err) && err != nil {
365 t.FailNow()
366 }
367 if err == nil {
368
369 b, err := os.ReadFile(rc.expectedOutputFilename)
370 if !assert.NoError(t, err) {
371 t.FailNow()
372 }
373 expectedOutput = string(b)
374 }
375 }
376 if rc.expectedErrorFilename != "" {
377 _, err := os.Stat(rc.expectedErrorFilename)
378 if !os.IsNotExist(err) && err != nil {
379 t.FailNow()
380 }
381 if err == nil {
382
383 b, err := os.ReadFile(rc.expectedErrorFilename)
384 if !assert.NoError(t, err) {
385 t.FailNow()
386 }
387 expectedError = string(b)
388 }
389 }
390 return expectedOutput, expectedError
391 }
392
393
394
395
396 func runAllTestCases(t *testing.T, checker resultsChecker) {
397 t.Helper()
398 checker.resetTestCasesRun()
399 err := filepath.Walk(checker.rootDir(),
400 func(path string, info os.FileInfo, err error) error {
401 require.NoError(t, err)
402 if info.IsDir() && checker.isTestDir(path) {
403 runDirectoryTestCase(t, path, checker)
404 }
405 return nil
406 })
407 require.NoError(t, err)
408 require.NotZero(t, len(checker.TestCasesRun()), "No complete test cases found in %s", checker.rootDir())
409 }
410
411 func runDirectoryTestCase(t *testing.T, path string, checker resultsChecker) {
412 t.Helper()
413
414 d, err := os.Getwd()
415 require.NoError(t, err)
416
417 defer func() { require.NoError(t, os.Chdir(d)) }()
418 require.NoError(t, os.Chdir(path))
419
420 expectedOutput, expectedError := checker.readAssertionFiles(t)
421 if expectedError == "" && expectedOutput == "" && !checker.shouldUpdateFixtures() {
422 t.Fatalf("test directory %s must include either expected output or expected error fixture", path)
423 }
424
425
426 t.Run(path, func(t *testing.T) {
427 checker.recordTestCase(path)
428 actualOutput, actualError := checker.runInCurrentDir(t)
429
430
431 if checker.shouldUpdateFixtures() {
432 checker.updateFixtures(t, actualOutput, actualError)
433 }
434
435
436 if expectedError != "" {
437
438 require.NotEmptyf(t, actualError, "test expected an error but message was empty, and output was:\n%s", actualOutput)
439 checker.assertErrorMatches(t, expectedError, actualError)
440 } else {
441
442 require.Emptyf(t, actualError, "test expected no error but got an error message, and output was:\n%s", actualOutput)
443 checker.assertOutputMatches(t, expectedOutput, actualOutput)
444 }
445 })
446 }
447
448 func atLeastOneFileExists(files ...string) bool {
449 for _, file := range files {
450 if _, err := os.Stat(file); err == nil {
451 return true
452 }
453 }
454 return false
455 }
456
View as plain text