-
Notifications
You must be signed in to change notification settings - Fork 48
Add TempDir and NewLogFile test utilities #369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
6657a87
947d5d0
edcca02
f7b2d26
92b0da2
62c526c
f7d1755
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| // Licensed to Elasticsearch B.V. under one or more contributor | ||
| // license agreements. See the NOTICE file distributed with | ||
| // this work for additional information regarding copyright | ||
| // ownership. Elasticsearch B.V. licenses this file to you under | ||
| // the Apache License, Version 2.0 (the "License"); you may | ||
| // not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| package fs | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
| "testing" | ||
| "unicode" | ||
| "unicode/utf8" | ||
| ) | ||
|
|
||
| // TempDir creates a temporary directory that will be | ||
| // removed if the tests passes. The temporary directory is | ||
| // created by joining all elements from path, with the sanitised | ||
| // test name. | ||
| // | ||
| // If path is empty, the temporary directory is created in os.TempDir. | ||
| // | ||
| // When tests are run with -v, the temporary directory absolute | ||
| // path will be logged. | ||
| func TempDir(t *testing.T, path ...string) string { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not a fan of copying so much of the code that exists in t.TempDir(). Can we use t.TempDir and then just have the cleanup function copy or archive the contents in the case of a failure?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally I like that the temp dir is already created on its final destination, so no matter how wrong the test goes, the files are kept. Copying/archiving the temp dir after a test failure adds another point of failure and complexity that I'd like to avoid.
I haven't copied much of it, well, the bit I copied was only the part sanitising the test name to create the folder. |
||
| rootDir := filepath.Join(path...) | ||
|
|
||
| if rootDir == "" { | ||
| rootDir = os.TempDir() | ||
| } | ||
|
|
||
| rootDir, err := filepath.Abs(rootDir) | ||
| if err != nil { | ||
| t.Fatalf("cannot get absolute path: %s", err) | ||
| } | ||
|
|
||
| // Logic copied with small modifications from | ||
| // the Go source code: testing/testing.go | ||
| folderName := t.Name() | ||
| mapper := func(r rune) rune { | ||
| if r < utf8.RuneSelf { | ||
| const allowed = "_-" | ||
| if '0' <= r && r <= '9' || | ||
| 'a' <= r && r <= 'z' || | ||
| 'A' <= r && r <= 'Z' { | ||
| return r | ||
| } | ||
| if strings.ContainsRune(allowed, r) { | ||
| return r | ||
| } | ||
| } else if unicode.IsLetter(r) || unicode.IsNumber(r) { | ||
| return r | ||
| } | ||
| return -1 | ||
| } | ||
| folderName = strings.Map(mapper, folderName) | ||
|
|
||
| if err := os.MkdirAll(rootDir, 0o750); err != nil { | ||
| t.Fatalf("error making test dir: %s: %s", rootDir, err) | ||
| } | ||
|
|
||
| tempDir, err := os.MkdirTemp(rootDir, folderName) | ||
| if err != nil { | ||
| t.Fatalf("failed to make temp directory: %s", err) | ||
| } | ||
|
|
||
| cleanup := func() { | ||
| if !t.Failed() { | ||
| if err := os.RemoveAll(tempDir); err != nil { | ||
| // Ungly workaround Windows limitations | ||
| // Windows does not support the Interrup signal, so it might | ||
| // happen that Filebeat is still running, keeping it's registry | ||
| // file open, thus preventing the temporary folder from being | ||
| // removed. So we log the error and move on without failing the | ||
| // test | ||
| if runtime.GOOS == "windows" { | ||
| t.Logf("[WARN] Could not remove temporatry directory '%s': %s", tempDir, err) | ||
| } else { | ||
| t.Errorf("could not remove temp dir '%s': %s", tempDir, err) | ||
| } | ||
| } | ||
| } else { | ||
| t.Logf("Temporary directory saved: %s", tempDir) | ||
| } | ||
| } | ||
| t.Cleanup(cleanup) | ||
|
|
||
| return tempDir | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| // Licensed to Elasticsearch B.V. under one or more contributor | ||
| // license agreements. See the NOTICE file distributed with | ||
| // this work for additional information regarding copyright | ||
| // ownership. Elasticsearch B.V. licenses this file to you under | ||
| // the Apache License, Version 2.0 (the "License"); you may | ||
| // not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| package fs | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "bytes" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestTempDir(t *testing.T) { | ||
| t.Run("temp dir is created using os.TempDir", func(t *testing.T) { | ||
| tempDir := TempDir(t) | ||
| osTempDir := os.TempDir() | ||
|
|
||
| baseDir := filepath.Dir(tempDir) | ||
| if baseDir != osTempDir { | ||
| t.Fatalf("expecting %q to be created on %q", tempDir, osTempDir) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("temp dir is created in the specified path", func(t *testing.T) { | ||
| path := []string{os.TempDir(), "foo", "bar"} | ||
| rootDir := filepath.Join(path...) | ||
|
|
||
| // Pass each element here so we ensure they're correctly joined | ||
| tempDir := TempDir(t, path...) | ||
|
|
||
| baseDir := filepath.Dir(tempDir) | ||
| if baseDir != rootDir { | ||
| t.Fatalf("expecting %q to be created on %q", tempDir, rootDir) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestTempDirIsKeptOnTestFailure(t *testing.T) { | ||
| rootDirEnv := "TEMPDIR_ROOTDIR" | ||
| tempFilename := "it-works" | ||
| if os.Getenv("INNER_TEST") == "1" { | ||
| rootDir := os.Getenv(rootDirEnv) | ||
| // We're inside the subprocess: | ||
| // 1. Read the root dir set as env var by the 'main' process | ||
| // 2. Use it when calling TempDir | ||
| // 3. Create a file just to ensure this actually run | ||
| tmpDir := TempDir(t, rootDir) | ||
| if err := os.WriteFile(filepath.Join(tmpDir, tempFilename), []byte("it works\n"), 0x666); err != nil { | ||
| t.Fatalf("cannot write temp file: %s", err) | ||
| } | ||
|
|
||
| t.Fatal("keep the folder") | ||
| return | ||
| } | ||
|
|
||
| rootDir := os.TempDir() | ||
| //nolint:gosec // This is intentionally running a subprocess | ||
| cmd := exec.CommandContext( | ||
| t.Context(), | ||
| os.Args[0], | ||
| fmt.Sprintf("-test.run=^%s$", | ||
| t.Name()), | ||
| "-test.v") | ||
| cmd.Env = append( | ||
| cmd.Env, | ||
| "INNER_TEST=1", | ||
| rootDir+"="+rootDir, | ||
belimawr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
| out, cmdErr := cmd.CombinedOutput() | ||
| if cmdErr != nil { | ||
| // The test ran by cmd will fail and retrun 1 as the exit code. So we only | ||
| // print the error if the main test fails. | ||
| defer func() { | ||
| if t.Failed() { | ||
| t.Errorf( | ||
| "the test process returned an error (this is expected in on a normal test execution): %s", | ||
belimawr marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| cmdErr) | ||
| t.Logf("Output of the subprocess:\n%s\n", string(out)) | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| var tempFolder string | ||
| sc := bufio.NewScanner(bytes.NewReader(out)) | ||
| for sc.Scan() { | ||
| txt := sc.Text() | ||
| // To extract the temp folder path we split txt in a way that the path | ||
| // is the 2nd element. | ||
| // The string we're using as reference: | ||
| // fs.go:97: Temporary directory saved: /tmp/TestTempDirIsKeptOnTestFailure2385221663 | ||
| if strings.Contains(txt, "Temporary directory saved:") { | ||
| split := strings.Split(txt, "Temporary directory saved: ") | ||
| if len(split) != 2 { | ||
| t.Fatalf("could not parse log file form test output, invalid format %q", txt) | ||
| } | ||
| tempFolder = split[1] | ||
| t.Cleanup(func() { | ||
| if t.Failed() { | ||
| t.Logf("Temp folder: %q", tempFolder) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| stat, err := os.Stat(tempFolder) | ||
| if err != nil { | ||
| t.Fatalf("cannot stat created temp folder: %s", err) | ||
| } | ||
|
|
||
| if !stat.IsDir() { | ||
| t.Errorf("%s must be a directory", tempFolder) | ||
| } | ||
|
|
||
| if _, err = os.Stat(filepath.Join(tempFolder, tempFilename)); err != nil { | ||
| t.Fatalf("cannot stat file create by subprocess: %s", err) | ||
| } | ||
|
|
||
| // Be nice and cleanup | ||
| if err := os.RemoveAll(tempFolder); err != nil { | ||
| t.Fatalf("cannot remove created folders: %s", err) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| // Licensed to Elasticsearch B.V. under one or more contributor | ||
| // license agreements. See the NOTICE file distributed with | ||
| // this work for additional information regarding copyright | ||
| // ownership. Elasticsearch B.V. licenses this file to you under | ||
| // the Apache License, Version 2.0 (the "License"); you may | ||
| // not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| package fs | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // LogFile wraps a *os.File and makes it more suitable for tests. | ||
| // Methods to search and wait for substrings in lines are provided, | ||
| // they keep track of the offset, ensuring ordering when | ||
| // when searching. | ||
| type LogFile struct { | ||
| *os.File | ||
| offset int64 | ||
| } | ||
|
|
||
| // NewLogFile returns a new LogFile which wraps a os.File meant to be used | ||
| // for testing. Methods to search and wait for strings in the file are | ||
| // provided. 'dir' and 'pattern' are passed directly to os.CreateTemp. | ||
| // It is the callers responsibility to remove the file. To keep the file | ||
| // when the test fails, use [TempDir] to create a folder. | ||
| func NewLogFile(t testing.TB, dir, pattern string) *LogFile { | ||
| f, err := os.CreateTemp(dir, pattern) | ||
| if err != nil { | ||
| t.Fatalf("cannot create log file: %s", err) | ||
| } | ||
|
|
||
| lf := &LogFile{ | ||
| File: f, | ||
| } | ||
|
|
||
| t.Cleanup(func() { | ||
| if err := f.Sync(); err != nil { | ||
| t.Logf("cannot sync log file: %s", err) | ||
| } | ||
|
|
||
| if err := f.Close(); err != nil { | ||
| t.Logf("cannot close log file: %s", err) | ||
| } | ||
| }) | ||
|
|
||
| return lf | ||
| } | ||
|
|
||
| // WaitLogsContains waits for the specified string s to be present in the logs within | ||
| // the given timeout duration and fails the test if s is not found. | ||
| // It keeps track of the log file offset, reading only new lines. Each | ||
| // subsequent call to WaitLogsContains will only check logs not yet evaluated. | ||
| // msgAndArgs should be a format string and arguments that will be printed | ||
| // if the logs are not found, providing additional context for debugging. | ||
| func (l *LogFile) WaitLogsContains(t testing.TB, s string, timeout time.Duration, msgAndArgs ...any) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this the same as the beats repo? https://github.com/elastic/beats/blob/main/libbeat/tests/integration/framework.go
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I've needed it in different places, like Elastic Agent. So this PR is a first attempt to centralise some helpers in a single place. |
||
| t.Helper() | ||
| require.EventuallyWithT( | ||
| t, | ||
| func(c *assert.CollectT) { | ||
| found, err := l.FindInLogs(s) | ||
| if err != nil { | ||
| c.Errorf("cannot check the log file: %s", err) | ||
| return | ||
| } | ||
|
|
||
| if !found { | ||
| c.Errorf("did not find '%s' in the logs", s) | ||
| } | ||
| }, | ||
| timeout, | ||
| 100*time.Millisecond, | ||
| msgAndArgs...) | ||
| } | ||
|
|
||
| // LogContains searches for str in the log file keeping track of the | ||
| // offset. If there is any issue reading the log file, then t.Fatalf is called, | ||
| // if str is not present in the logs, t.Errorf is called. | ||
| func (l *LogFile) LogContains(t testing.TB, str string) { | ||
| t.Helper() | ||
| found, err := l.FindInLogs(str) | ||
| if err != nil { | ||
| t.Fatalf("cannot read log file: %s", err) | ||
| } | ||
|
|
||
| if !found { | ||
| t.Errorf("'%s' not found in logs", str) | ||
| } | ||
| } | ||
|
|
||
| // FindInLogs searches for str in the log file keeping track of the offset. | ||
| // It returns true if str is found in the logs. If there are any errors, | ||
| // it returns false and the error | ||
| func (l *LogFile) FindInLogs(str string) (bool, error) { | ||
| // Open the file again so we can seek and not interfere with | ||
| // the logger writing to it. | ||
| f, err := os.Open(l.Name()) | ||
| if err != nil { | ||
| return false, fmt.Errorf("cannot open log file for reading: %w", err) | ||
| } | ||
|
|
||
| if _, err := f.Seek(l.offset, io.SeekStart); err != nil { | ||
| return false, fmt.Errorf("cannot seek log file: %w", err) | ||
| } | ||
|
|
||
| r := bufio.NewReader(f) | ||
| for { | ||
| data, err := r.ReadBytes('\n') | ||
| line := string(data) | ||
| l.offset += int64(len(data)) | ||
|
|
||
| if err != nil { | ||
| if !errors.Is(err, io.EOF) { | ||
| return false, fmt.Errorf("error reading log file '%s': %w", l.Name(), err) | ||
| } | ||
| break | ||
| } | ||
|
|
||
| if strings.Contains(line, str) { | ||
| return true, nil | ||
| } | ||
| } | ||
|
|
||
| return false, nil | ||
| } | ||
|
|
||
| // ResetOffset resets the log file offset | ||
| func (l *LogFile) ResetOffset() { | ||
| l.offset = 0 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: what's the difference with the stdlib t tmpdir ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TempDircreates a folder that when the test fails is kept and the path logged. This allows us to debug the state the test was running both locally and on CI.We have number of tests in Beats and Elastic Agent that create configuration files, logs to be ingested, etc. When those tests fail and all state is gone, debugging becomes extremely hard.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have similar functionality spread across many tests/test frameworks in Beats and Elastic Agent, I'm trying to consolidate them.
I've heavily needed and relayed on this functionality in the past couple of weeks to debug a number of tests, after duplicating the code in a few PRs I realised I needed it in a central place, with a single implementation.