Files
gibidify/testutil/testutil.go
Ismo Vuorinen 95b7ef6dd3 chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling

- Upgrade to Go 1.25
- Add benchmark targets to Makefile
- Implement parallel gosec execution
- Lock tool versions for reproducibility
- Add shellcheck directives to scripts
- Update CI workflows with improved caching

* refactor: migrate from golangci-lint to revive

- Replace golangci-lint with revive for linting
- Configure comprehensive revive rules
- Fix all EditorConfig violations
- Add yamllint and yamlfmt support
- Remove deprecated .golangci.yml

* refactor: rename utils to shared and deduplicate code

- Rename utils package to shared
- Add shared constants package
- Deduplicate constants across packages
- Address CodeRabbit review feedback

* fix: resolve SonarQube issues and add safety guards

- Fix all 73 SonarQube OPEN issues
- Add nil guards for resourceMonitor, backpressure, metricsCollector
- Implement io.Closer for headerFileReader
- Propagate errors from processing helpers
- Add metrics and templates packages
- Improve error handling across codebase

* test: improve test infrastructure and coverage

- Add benchmarks for cli, fileproc, metrics
- Improve test coverage for cli, fileproc, config
- Refactor tests with helper functions
- Add shared test constants
- Fix test function naming conventions
- Reduce cognitive complexity in benchmark tests

* docs: update documentation and configuration examples

- Update CLAUDE.md with current project state
- Refresh README with new features
- Add usage and configuration examples
- Add SonarQube project configuration
- Consolidate config.example.yaml

* fix: resolve shellcheck warnings in scripts

- Use ./*.go instead of *.go to prevent dash-prefixed filenames
  from being interpreted as options (SC2035)
- Remove unreachable return statement after exit (SC2317)
- Remove obsolete gibidiutils/ directory reference

* chore(deps): upgrade go dependencies

* chore(lint): megalinter fixes

* fix: improve test coverage and fix file descriptor leaks

- Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests
- Refactor TestProcessorConfigureFileTypes with helper functions and assertions
- Refactor TestProcessorLogFinalStats with output capture and keyword verification
- Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.)
- Reduce cognitive complexity by extracting helper functions

* fix: align test comments with function names

Remove underscores from test comments to match actual function names:
- benchmark/benchmark_test.go (2 fixes)
- fileproc/filetypes_config_test.go (4 fixes)
- fileproc/filetypes_registry_test.go (6 fixes)
- fileproc/processor_test.go (6 fixes)
- fileproc/resource_monitor_types_test.go (4 fixes)
- fileproc/writer_test.go (3 fixes)

* fix: various test improvements and bug fixes

- Remove duplicate maxCacheSize check in filetypes_registry_test.go
- Shorten long comment in processor_test.go to stay under 120 chars
- Remove flaky time.Sleep in collector_test.go, use >= 0 assertion
- Close pipe reader in benchmark_test.go to fix file descriptor leak
- Use ContinueOnError in flags_test.go to match ResetFlags behavior
- Add nil check for p.ui in processor_workers.go before UpdateProgress
- Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly

* chore(yaml): add missing document start markers

Add --- document start to YAML files to satisfy yamllint:
- .github/workflows/codeql.yml
- .github/workflows/build-test-publish.yml
- .github/workflows/security.yml
- .github/actions/setup/action.yml

* fix: guard nil resourceMonitor and fix test deadlock

- Guard resourceMonitor before CreateFileProcessingContext call
- Add ui.UpdateProgress on emergency stop and path error returns
- Fix potential deadlock in TestProcessFile using wg.Go with defer close
2025-12-10 19:07:11 +02:00

414 lines
12 KiB
Go

// Package testutil provides common testing utilities and helper functions.
//
// Testing Patterns and Conventions:
//
// File Setup:
// - Use CreateTestFile() for individual files
// - Use CreateTestFiles() for multiple files from FileSpec
// - Use CreateTestDirectoryStructure() for complex directory trees
// - Use SetupTempDirWithStructure() for complete test environments
//
// Error Assertions:
// - Use AssertError() for conditional error checking
// - Use AssertNoError() when expecting success
// - Use AssertExpectedError() when expecting failure
// - Use AssertErrorContains() for substring validation
//
// Configuration:
// - Use ResetViperConfig() to reset between tests
// - Remember to call config.LoadConfig() after ResetViperConfig()
//
// Best Practices:
// - Always use t.Helper() in test helper functions
// - Use descriptive operation names in assertions
// - Prefer table-driven tests for multiple scenarios
// - Use testutil.ErrTestError for standard test errors
package testutil
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/spf13/viper"
"github.com/ivuorinen/gibidify/config"
"github.com/ivuorinen/gibidify/shared"
)
// SuppressLogs suppresses logger output during testing to keep test output clean.
// Returns a function that should be called to restore the original log output.
func SuppressLogs(t *testing.T) func() {
t.Helper()
logger := shared.GetLogger()
// Capture original output by temporarily setting it to discard
logger.SetOutput(io.Discard)
// Return function to restore original settings (stderr)
return func() {
logger.SetOutput(os.Stderr)
}
}
// OutputRestoreFunc represents a function that restores output after suppression.
type OutputRestoreFunc func()
// SuppressAllOutput suppresses both stdout and stderr during testing.
// This captures all output including UI messages, progress bars, and direct prints.
// Returns a function that should be called to restore original output.
func SuppressAllOutput(t *testing.T) OutputRestoreFunc {
t.Helper()
// Save original stdout and stderr
originalStdout := os.Stdout
originalStderr := os.Stderr
// Suppress logger output as well
logger := shared.GetLogger()
logger.SetOutput(io.Discard)
// Open /dev/null for safe redirection
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("Failed to open devnull: %v", err)
}
// Redirect both stdout and stderr to /dev/null
os.Stdout = devNull
os.Stderr = devNull
// Return restore function
return func() {
// Close devNull first
if devNull != nil {
_ = devNull.Close() // Ignore close errors in cleanup
}
// Restore original outputs
os.Stdout = originalStdout
os.Stderr = originalStderr
logger.SetOutput(originalStderr)
}
}
// CaptureOutput captures both stdout and stderr during test execution.
// Returns the captured output as strings and a restore function.
func CaptureOutput(t *testing.T) (getStdout func() string, getStderr func() string, restore OutputRestoreFunc) {
t.Helper()
// Save original outputs
originalStdout := os.Stdout
originalStderr := os.Stderr
// Create pipes for stdout
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
t.Fatalf("Failed to create stdout pipe: %v", err)
}
// Create pipes for stderr
stderrReader, stderrWriter, err := os.Pipe()
if err != nil {
t.Fatalf("Failed to create stderr pipe: %v", err)
}
// Redirect outputs
os.Stdout = stdoutWriter
os.Stderr = stderrWriter
// Suppress logger output to stderr
logger := shared.GetLogger()
logger.SetOutput(stderrWriter)
// Buffers to collect output
var stdoutBuf, stderrBuf bytes.Buffer
// Start goroutines to read from pipes
stdoutDone := make(chan struct{})
stderrDone := make(chan struct{})
go func() {
defer close(stdoutDone)
_, _ = io.Copy(&stdoutBuf, stdoutReader) //nolint:errcheck // Ignore errors during test output capture shutdown
}()
go func() {
defer close(stderrDone)
_, _ = io.Copy(&stderrBuf, stderrReader) //nolint:errcheck // Ignore errors during test output capture shutdown
}()
return func() string {
return stdoutBuf.String()
}, func() string {
return stderrBuf.String()
}, func() {
// Close writers first to signal EOF
_ = stdoutWriter.Close() // Ignore close errors in cleanup
_ = stderrWriter.Close() // Ignore close errors in cleanup
// Wait for readers to finish
<-stdoutDone
<-stderrDone
// Close readers
_ = stdoutReader.Close() // Ignore close errors in cleanup
_ = stderrReader.Close() // Ignore close errors in cleanup
// Restore original outputs
os.Stdout = originalStdout
os.Stderr = originalStderr
logger.SetOutput(originalStderr)
}
}
// CreateTestFile creates a test file with the given content and returns its path.
func CreateTestFile(t *testing.T, dir, filename string, content []byte) string {
t.Helper()
filePath := filepath.Join(dir, filename)
if err := os.WriteFile(filePath, content, shared.TestFilePermission); err != nil {
t.Fatalf("Failed to write file %s: %v", filePath, err)
}
return filePath
}
// CreateTempOutputFile creates a temporary output file and returns the file handle and path.
func CreateTempOutputFile(t *testing.T, pattern string) (file *os.File, path string) {
t.Helper()
outFile, err := os.CreateTemp(t.TempDir(), pattern)
if err != nil {
t.Fatalf("Failed to create temp output file: %v", err)
}
path = outFile.Name()
return outFile, path
}
// CreateTestDirectory creates a test directory and returns its path.
func CreateTestDirectory(t *testing.T, parent, name string) string {
t.Helper()
dirPath := filepath.Join(parent, name)
if err := os.Mkdir(dirPath, shared.TestDirPermission); err != nil {
t.Fatalf("Failed to create directory %s: %v", dirPath, err)
}
return dirPath
}
// FileSpec represents a file specification for creating test files.
type FileSpec struct {
Name string
Content string
}
// CreateTestFiles creates multiple test files from specifications.
func CreateTestFiles(t *testing.T, rootDir string, fileSpecs []FileSpec) []string {
t.Helper()
createdFiles := make([]string, 0, len(fileSpecs))
for _, spec := range fileSpecs {
filePath := CreateTestFile(t, rootDir, spec.Name, []byte(spec.Content))
createdFiles = append(createdFiles, filePath)
}
return createdFiles
}
// ResetViperConfig resets Viper configuration and optionally sets a config path.
func ResetViperConfig(t *testing.T, configPath string) {
t.Helper()
viper.Reset()
if configPath != "" {
viper.AddConfigPath(configPath)
}
config.LoadConfig()
}
// SetViperKeys sets specific configuration keys for testing.
func SetViperKeys(t *testing.T, keyValues map[string]any) {
t.Helper()
viper.Reset()
for key, value := range keyValues {
viper.Set(key, value)
}
config.LoadConfig()
}
// ApplyBackpressureOverrides applies backpressure configuration overrides for testing.
// This is a convenience wrapper around SetViperKeys specifically for backpressure tests.
func ApplyBackpressureOverrides(t *testing.T, overrides map[string]any) {
t.Helper()
SetViperKeys(t, overrides)
}
// SetupCLIArgs configures os.Args for CLI testing.
func SetupCLIArgs(srcDir, outFilePath, prefix, suffix string, concurrency int) {
os.Args = []string{
"gibidify",
"-source", srcDir,
"-destination", outFilePath,
"-prefix", prefix,
"-suffix", suffix,
"-concurrency", strconv.Itoa(concurrency),
"-no-ui", // Suppress UI output during tests
}
}
// VerifyContentContains checks that content contains all expected substrings.
func VerifyContentContains(t *testing.T, content string, expectedSubstrings []string) {
t.Helper()
for _, expected := range expectedSubstrings {
if !strings.Contains(content, expected) {
t.Errorf("Content missing expected substring: %s", expected)
}
}
}
// MustSucceed fails the test if the error is not nil.
func MustSucceed(t *testing.T, err error, operation string) {
t.Helper()
if err != nil {
t.Fatalf(shared.TestMsgOperationFailed, operation, err)
}
}
// CloseFile closes a file and reports errors to the test.
func CloseFile(t *testing.T, file *os.File) {
t.Helper()
if err := file.Close(); err != nil {
t.Errorf("Failed to close file: %v", err)
}
}
// BaseName returns the base name of a file path (filename without directory).
func BaseName(path string) string {
return filepath.Base(path)
}
// Advanced directory setup patterns.
// DirSpec represents a directory specification for creating test directory structures.
type DirSpec struct {
Path string
Files []FileSpec
}
// CreateTestDirectoryStructure creates multiple directories with files.
func CreateTestDirectoryStructure(t *testing.T, rootDir string, dirSpecs []DirSpec) []string {
t.Helper()
createdPaths := make([]string, 0)
for _, dirSpec := range dirSpecs {
dirPath := filepath.Join(rootDir, dirSpec.Path)
if err := os.MkdirAll(dirPath, shared.TestDirPermission); err != nil {
t.Fatalf("Failed to create directory structure %s: %v", dirPath, err)
}
createdPaths = append(createdPaths, dirPath)
// Create files in the directory
for _, fileSpec := range dirSpec.Files {
filePath := CreateTestFile(t, dirPath, fileSpec.Name, []byte(fileSpec.Content))
createdPaths = append(createdPaths, filePath)
}
}
return createdPaths
}
// SetupTempDirWithStructure creates a temp directory with a structured layout.
func SetupTempDirWithStructure(t *testing.T, dirSpecs []DirSpec) string {
t.Helper()
rootDir := t.TempDir()
CreateTestDirectoryStructure(t, rootDir, dirSpecs)
return rootDir
}
// Error assertion helpers - safe to use across packages.
// AssertError checks if an error matches the expected state.
// If wantErr is true, expects err to be non-nil.
// If wantErr is false, expects err to be nil and fails if it's not.
func AssertError(t *testing.T, err error, wantErr bool, operation string) {
t.Helper()
if (err != nil) != wantErr {
if wantErr {
t.Errorf(shared.TestMsgOperationNoError, operation)
} else {
t.Errorf("Operation %s unexpected error: %v", operation, err)
}
}
}
// AssertNoError fails the test if err is not nil.
func AssertNoError(t *testing.T, err error, operation string) {
t.Helper()
if err != nil {
t.Errorf(shared.TestMsgOperationFailed, operation, err)
}
}
// AssertExpectedError fails the test if err is nil when an error is expected.
func AssertExpectedError(t *testing.T, err error, operation string) {
t.Helper()
if err == nil {
t.Errorf(shared.TestMsgOperationNoError, operation)
}
}
// AssertErrorContains checks that error contains the expected substring.
func AssertErrorContains(t *testing.T, err error, expectedSubstring, operation string) {
t.Helper()
if err == nil {
t.Errorf("Operation %s expected error containing %q but got none", operation, expectedSubstring)
return
}
if !strings.Contains(err.Error(), expectedSubstring) {
t.Errorf("Operation %s error %q should contain %q", operation, err.Error(), expectedSubstring)
}
}
// ValidateErrorCase checks error expectations and optionally validates error message content.
// This is a comprehensive helper that combines error checking with substring matching.
func ValidateErrorCase(t *testing.T, err error, wantErr bool, errContains string, operation string) {
t.Helper()
if wantErr {
if err == nil {
t.Errorf("%s: expected error but got none", operation)
return
}
if errContains != "" && !strings.Contains(err.Error(), errContains) {
t.Errorf("%s: expected error containing %q, got: %v", operation, errContains, err)
}
} else {
if err != nil {
t.Errorf("%s: unexpected error: %v", operation, err)
}
}
}
// VerifyStructuredError validates StructuredError properties.
// This helper ensures structured errors have the expected Type and Code values.
func VerifyStructuredError(t *testing.T, err error, expectedType shared.ErrorType, expectedCode string) {
t.Helper()
var structErr *shared.StructuredError
if !errors.As(err, &structErr) {
t.Errorf("expected StructuredError, got: %T", err)
return
}
if structErr.Type != expectedType {
t.Errorf("expected Type %v, got %v", expectedType, structErr.Type)
}
if structErr.Code != expectedCode {
t.Errorf("expected Code %q, got %q", expectedCode, structErr.Code)
}
}