Files
kustomize/kyaml/fn/framework/frameworktestutil/frameworktestutil.go
2022-08-10 18:22:46 -04:00

456 lines
16 KiB
Go

// Copyright 2020 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
// Package frameworktestutil contains utilities for testing functions written using the framework.
package frameworktestutil
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/kustomize/kyaml/fn/framework"
"sigs.k8s.io/kustomize/kyaml/kio"
)
const (
DefaultTestDataDirectory = "testdata"
DefaultConfigInputFilename = "config.yaml"
DefaultInputFilename = "input.yaml"
DefaultInputFilenameGlob = "input*.yaml"
DefaultOutputFilename = "expected.yaml"
DefaultErrorFilename = "errors.txt"
)
// CommandResultsChecker tests a command-wrapped function by running it with predefined inputs
// and comparing the outputs to expected results.
type CommandResultsChecker struct {
// TestDataDirectory is the directory containing the testdata subdirectories.
// CommandResultsChecker will recurse into each test directory and run the Command
// if the directory contains at least one of ExpectedOutputFilename or ExpectedErrorFilename.
// Defaults to "testdata"
TestDataDirectory string
// ExpectedOutputFilename is the file with the expected output of the function
// Defaults to "expected.yaml". Directories containing neither this file
// nor ExpectedErrorFilename will be skipped.
ExpectedOutputFilename string
// ExpectedErrorFilename is the file containing elements of an expected error message.
// Each line of the file will be treated as a regex that must match the actual error.
// Defaults to "errors.txt". Directories containing neither this file
// nor ExpectedOutputFilename will be skipped.
ExpectedErrorFilename string
// UpdateExpectedFromActual if set to true will write the actual results to the
// expected testdata files. This is useful for updating test data.
UpdateExpectedFromActual bool
// OutputAssertionFunc allows you to swap out the logic used to compare the expected output
// from the fixture file to the actual output.
// By default, it performs a string comparison after normalizing whitespace.
OutputAssertionFunc AssertionFunc
// ErrorAssertionFunc allows you to swap out the logic used to compare the expected error
// message from the fixture file to the actual error message.
// By default, it interprets each line of the fixture as a regex that the actual error must match.
ErrorAssertionFunc AssertionFunc
// ConfigInputFilename is the name of the config file provided as the first
// argument to the function. Directories without this file will be skipped.
// Defaults to "config.yaml"
ConfigInputFilename string
// InputFilenameGlob matches function inputs
// Defaults to "input*.yaml"
InputFilenameGlob string
// Command provides the function to run.
Command func() *cobra.Command
*checkerCore
}
// Assert runs the command with the input provided in each valid test directory
// and verifies that the actual output and error match the fixtures in the directory.
func (rc *CommandResultsChecker) Assert(t *testing.T) bool {
t.Helper()
if rc.ConfigInputFilename == "" {
rc.ConfigInputFilename = DefaultConfigInputFilename
}
if rc.InputFilenameGlob == "" {
rc.InputFilenameGlob = DefaultInputFilenameGlob
}
rc.checkerCore = &checkerCore{
testDataDirectory: rc.TestDataDirectory,
expectedOutputFilename: rc.ExpectedOutputFilename,
expectedErrorFilename: rc.ExpectedErrorFilename,
updateExpectedFromActual: rc.UpdateExpectedFromActual,
outputAssertionFunc: rc.OutputAssertionFunc,
errorAssertionFunc: rc.ErrorAssertionFunc,
}
rc.checkerCore.setDefaults()
runAllTestCases(t, rc)
return true
}
func (rc *CommandResultsChecker) isTestDir(path string) bool {
return atLeastOneFileExists(
filepath.Join(path, rc.ConfigInputFilename),
filepath.Join(path, rc.checkerCore.expectedOutputFilename),
filepath.Join(path, rc.checkerCore.expectedErrorFilename),
)
}
func (rc *CommandResultsChecker) runInCurrentDir(t *testing.T) (string, string) {
t.Helper()
_, err := os.Stat(rc.ConfigInputFilename)
if os.IsNotExist(err) {
t.Errorf("Test case is missing FunctionConfig input file (default: %s)", DefaultConfigInputFilename)
}
require.NoError(t, err)
args := []string{rc.ConfigInputFilename}
if rc.InputFilenameGlob != "" {
inputs, err := filepath.Glob(rc.InputFilenameGlob)
require.NoError(t, err)
args = append(args, inputs...)
}
var stdOut, stdErr bytes.Buffer
cmd := rc.Command()
cmd.SetArgs(args)
cmd.SetOut(&stdOut)
cmd.SetErr(&stdErr)
_ = cmd.Execute()
return stdOut.String(), stdErr.String()
}
// ProcessorResultsChecker tests a processor function by running it with predefined inputs
// and comparing the outputs to expected results.
type ProcessorResultsChecker struct {
// TestDataDirectory is the directory containing the testdata subdirectories.
// ProcessorResultsChecker will recurse into each test directory and run the Command
// if the directory contains at least one of ExpectedOutputFilename or ExpectedErrorFilename.
// Defaults to "testdata"
TestDataDirectory string
// ExpectedOutputFilename is the file with the expected output of the function
// Defaults to "expected.yaml". Directories containing neither this file
// nor ExpectedErrorFilename will be skipped.
ExpectedOutputFilename string
// ExpectedErrorFilename is the file containing elements of an expected error message.
// Each line of the file will be treated as a regex that must match the actual error.
// Defaults to "errors.txt". Directories containing neither this file
// nor ExpectedOutputFilename will be skipped.
ExpectedErrorFilename string
// UpdateExpectedFromActual if set to true will write the actual results to the
// expected testdata files. This is useful for updating test data.
UpdateExpectedFromActual bool
// InputFilename is the name of the file containing the ResourceList input.
// Directories without this file will be skipped.
// Defaults to "input.yaml"
InputFilename string
// OutputAssertionFunc allows you to swap out the logic used to compare the expected output
// from the fixture file to the actual output.
// By default, it performs a string comparison after normalizing whitespace.
OutputAssertionFunc AssertionFunc
// ErrorAssertionFunc allows you to swap out the logic used to compare the expected error
// message from the fixture file to the actual error message.
// By default, it interprets each line of the fixture as a regex that the actual error must match.
ErrorAssertionFunc AssertionFunc
// Processor returns a ResourceListProcessor to run.
Processor func() framework.ResourceListProcessor
*checkerCore
}
// Assert runs the processor with the input provided in each valid test directory
// and verifies that the actual output and error match the fixtures in the directory.
func (rc *ProcessorResultsChecker) Assert(t *testing.T) bool {
t.Helper()
if rc.InputFilename == "" {
rc.InputFilename = DefaultInputFilename
}
rc.checkerCore = &checkerCore{
testDataDirectory: rc.TestDataDirectory,
expectedOutputFilename: rc.ExpectedOutputFilename,
expectedErrorFilename: rc.ExpectedErrorFilename,
updateExpectedFromActual: rc.UpdateExpectedFromActual,
outputAssertionFunc: rc.OutputAssertionFunc,
errorAssertionFunc: rc.ErrorAssertionFunc,
}
rc.checkerCore.setDefaults()
runAllTestCases(t, rc)
return true
}
func (rc *ProcessorResultsChecker) isTestDir(path string) bool {
return atLeastOneFileExists(
filepath.Join(path, rc.InputFilename),
filepath.Join(path, rc.checkerCore.expectedOutputFilename),
filepath.Join(path, rc.checkerCore.expectedErrorFilename),
)
}
func (rc *ProcessorResultsChecker) runInCurrentDir(t *testing.T) (string, string) {
t.Helper()
_, err := os.Stat(rc.InputFilename)
if os.IsNotExist(err) {
t.Errorf("Test case is missing input file (default: %s)", DefaultInputFilename)
}
require.NoError(t, err)
actualOutput := bytes.NewBuffer([]byte{})
rlBytes, err := os.ReadFile(rc.InputFilename)
require.NoError(t, err)
rw := kio.ByteReadWriter{
Reader: bytes.NewBuffer(rlBytes),
Writer: actualOutput,
}
err = framework.Execute(rc.Processor(), &rw)
if err != nil {
require.NotEmptyf(t, err.Error(), "processor returned error with empty message")
return actualOutput.String(), err.Error()
}
return actualOutput.String(), ""
}
type AssertionFunc func(t *testing.T, expected string, actual string)
// RequireEachLineMatches is an AssertionFunc that treats each line of expected string
// as a regex that must match the actual string.
func RequireEachLineMatches(t *testing.T, expected, actual string) {
t.Helper()
// Check that each expected line matches the output
actual = standardizeSpacing(actual)
for _, msg := range strings.Split(standardizeSpacing(expected), "\n") {
require.Regexp(t, regexp.MustCompile(msg), actual)
}
}
// RequireStrippedStringsEqual is an AssertionFunc that does a simple string comparison
// of expected and actual after normalizing whitespace.
func RequireStrippedStringsEqual(t *testing.T, expected, actual string) {
t.Helper()
require.Equal(t,
standardizeSpacing(expected),
standardizeSpacing(actual))
}
func standardizeSpacing(s string) string {
// remove extra whitespace and convert Windows line endings
return strings.ReplaceAll(strings.TrimSpace(s), "\r\n", "\n")
}
// resultsChecker is implemented by ProcessorResultsChecker and CommandResultsChecker, partially via checkerCore
type resultsChecker interface {
// TestCasesRun returns a list of the test case directories that have been seen.
TestCasesRun() (paths []string)
// rootDir is the root of the tree of test directories to be searched for fixtures.
rootDir() string
// isTestDir takes the name of directory and returns whether or not it contains the files required to be a test case.
isTestDir(dir string) bool
// runInCurrentDir executes the code the checker is checking in the context of the current working directory.
runInCurrentDir(t *testing.T) (stdOut, stdErr string)
// resetTestCasesRun resets the list of test case directories seen.
resetTestCasesRun()
// recordTestCase adds to the list of test case directories seen.
recordTestCase(path string)
// readAssertionFiles returns the contents of the output and error fixtures
readAssertionFiles(t *testing.T) (expectedOutput string, expectedError string)
// shouldUpdateFixtures indicates whether or not this checker is currently being used to update fixtures instead of run them.
shouldUpdateFixtures() bool
// updateFixtures modifies the test fixture files to match the given content
updateFixtures(t *testing.T, actualOutput string, actualError string)
// assertOutputMatches compares the expected output to the output received.
assertOutputMatches(t *testing.T, expected string, actual string)
// assertErrorMatches compares the expected error to the error received.
assertErrorMatches(t *testing.T, expected string, actual string)
}
// checkerCore implements the resultsChecker methods that are shared between the two checker types
type checkerCore struct {
testDataDirectory string
expectedOutputFilename string
expectedErrorFilename string
updateExpectedFromActual bool
outputAssertionFunc AssertionFunc
errorAssertionFunc AssertionFunc
testsCasesRun []string
}
func (rc *checkerCore) setDefaults() {
if rc.testDataDirectory == "" {
rc.testDataDirectory = DefaultTestDataDirectory
}
if rc.expectedOutputFilename == "" {
rc.expectedOutputFilename = DefaultOutputFilename
}
if rc.expectedErrorFilename == "" {
rc.expectedErrorFilename = DefaultErrorFilename
}
if rc.outputAssertionFunc == nil {
rc.outputAssertionFunc = RequireStrippedStringsEqual
}
if rc.errorAssertionFunc == nil {
rc.errorAssertionFunc = RequireEachLineMatches
}
}
func (rc *checkerCore) rootDir() string {
return rc.testDataDirectory
}
func (rc *checkerCore) TestCasesRun() []string {
return rc.testsCasesRun
}
func (rc *checkerCore) resetTestCasesRun() {
rc.testsCasesRun = []string{}
}
func (rc *checkerCore) recordTestCase(s string) {
rc.testsCasesRun = append(rc.testsCasesRun, s)
}
func (rc *checkerCore) shouldUpdateFixtures() bool {
return rc.updateExpectedFromActual
}
func (rc *checkerCore) updateFixtures(t *testing.T, actualOutput string, actualError string) {
t.Helper()
if actualError != "" {
require.NoError(t, os.WriteFile(rc.expectedErrorFilename, []byte(actualError), 0600))
}
if len(actualOutput) > 0 {
require.NoError(t, os.WriteFile(rc.expectedOutputFilename, []byte(actualOutput), 0600))
}
t.Skip("Updated fixtures for test case")
}
func (rc *checkerCore) assertOutputMatches(t *testing.T, expected string, actual string) {
t.Helper()
rc.outputAssertionFunc(t, expected, actual)
}
func (rc *checkerCore) assertErrorMatches(t *testing.T, expected string, actual string) {
t.Helper()
rc.errorAssertionFunc(t, expected, actual)
}
func (rc *checkerCore) readAssertionFiles(t *testing.T) (string, string) {
t.Helper()
// read the expected results
var expectedOutput, expectedError string
if rc.expectedOutputFilename != "" {
_, err := os.Stat(rc.expectedOutputFilename)
if !os.IsNotExist(err) && err != nil {
t.FailNow()
}
if err == nil {
// only read the file if it exists
b, err := os.ReadFile(rc.expectedOutputFilename)
if !assert.NoError(t, err) {
t.FailNow()
}
expectedOutput = string(b)
}
}
if rc.expectedErrorFilename != "" {
_, err := os.Stat(rc.expectedErrorFilename)
if !os.IsNotExist(err) && err != nil {
t.FailNow()
}
if err == nil {
// only read the file if it exists
b, err := os.ReadFile(rc.expectedErrorFilename)
if !assert.NoError(t, err) {
t.FailNow()
}
expectedError = string(b)
}
}
return expectedOutput, expectedError
}
// runAllTestCases traverses rootDir to find test cases, calls getResult to invoke the function
// under test in each directory, and then runs assertions on the returned output and error results.
// It triggers a test failure if no valid test directories were found.
func runAllTestCases(t *testing.T, checker resultsChecker) {
t.Helper()
checker.resetTestCasesRun()
err := filepath.Walk(checker.rootDir(),
func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
if info.IsDir() && checker.isTestDir(path) {
runDirectoryTestCase(t, path, checker)
}
return nil
})
require.NoError(t, err)
require.NotZero(t, len(checker.TestCasesRun()), "No complete test cases found in %s", checker.rootDir())
}
func runDirectoryTestCase(t *testing.T, path string, checker resultsChecker) {
t.Helper()
// cd into the directory so we can test functions that refer to local files by relative paths
d, err := os.Getwd()
require.NoError(t, err)
defer func() { require.NoError(t, os.Chdir(d)) }()
require.NoError(t, os.Chdir(path))
expectedOutput, expectedError := checker.readAssertionFiles(t)
if expectedError == "" && expectedOutput == "" && !checker.shouldUpdateFixtures() {
t.Fatalf("test directory %s must include either expected output or expected error fixture", path)
}
// run the test
t.Run(path, func(t *testing.T) {
checker.recordTestCase(path)
actualOutput, actualError := checker.runInCurrentDir(t)
// Configured to update the assertion files instead of comparing them
if checker.shouldUpdateFixtures() {
checker.updateFixtures(t, actualOutput, actualError)
}
// Compare the results to the assertion files
if expectedError != "" {
// We expected an error, so make sure there was one
require.NotEmptyf(t, actualError, "test expected an error but message was empty, and output was:\n%s", actualOutput)
checker.assertErrorMatches(t, expectedError, actualError)
} else {
// We didn't expect an error, and the output should match
require.Emptyf(t, actualError, "test expected no error but got an error message, and output was:\n%s", actualOutput)
checker.assertOutputMatches(t, expectedOutput, actualOutput)
}
})
}
func atLeastOneFileExists(files ...string) bool {
for _, file := range files {
if _, err := os.Stat(file); err == nil {
return true
}
}
return false
}