Files
gh-action-readme/testutil/testutil.go
Ismo Vuorinen 4f12c4d3dd feat(lint): add many linters, make all the tests run fast! (#23)
* chore(lint): added nlreturn, run linting

* chore(lint): replace some fmt.Sprintf calls

* chore(lint): replace fmt.Sprintf with strconv

* chore(lint): add goconst, use http lib for status codes, and methods

* chore(lint): use errors lib, errCodes from internal/errors

* chore(lint): dupl, thelper and usetesting

* chore(lint): fmt.Errorf %v to %w, more linters

* chore(lint): paralleltest, where possible

* perf(test): optimize test performance by 78%

- Implement shared binary building with package-level cache to eliminate redundant builds
- Add strategic parallelization to 15+ tests while preserving environment variable isolation
- Implement thread-safe fixture caching with RWMutex to reduce I/O operations
- Remove unnecessary working directory changes by leveraging embedded templates
- Add embedded template system with go:embed directive for reliable template resolution
- Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive

Performance improvements:
- Total test execution time: 12+ seconds → 2.7 seconds (78% faster)
- Binary build overhead: 14+ separate builds → 1 shared build (93% reduction)
- Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage)
- I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction)

All tests maintain 100% success rate and coverage while running nearly 4x faster.
2025-08-06 15:28:09 +03:00

429 lines
11 KiB
Go

// Package testutil provides testing utilities and mocks for gh-action-readme.
package testutil
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-github/v57/github"
)
// MockHTTPClient is a mock HTTP client for testing.
type MockHTTPClient struct {
Responses map[string]*http.Response
Requests []*http.Request
}
// HTTPResponse represents a mock HTTP response.
type HTTPResponse struct {
StatusCode int
Body string
Headers map[string]string
}
// HTTPRequest represents a captured HTTP request.
type HTTPRequest struct {
Method string
URL string
Body string
Headers map[string]string
}
// Do implements the http.Client interface.
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
m.Requests = append(m.Requests, req)
key := req.Method + " " + req.URL.String()
if resp, ok := m.Responses[key]; ok {
return resp, nil
}
// Default 404 response
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader(`{"error": "not found"}`)),
}, nil
}
// MockGitHubClient creates a GitHub client with mocked responses.
func MockGitHubClient(responses map[string]string) *github.Client {
mockClient := &MockHTTPClient{
Responses: make(map[string]*http.Response),
}
for key, body := range responses {
mockClient.Responses[key] = &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
return client
}
type mockTransport struct {
client *MockHTTPClient
}
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.client.Do(req)
}
// TempDir creates a temporary directory for testing and returns cleanup function.
func TempDir(t *testing.T) (string, func()) {
t.Helper()
dir := t.TempDir()
return dir, func() {
// t.TempDir() automatically cleans up, so no action needed
}
}
// WriteTestFile writes a test file to the given path.
func WriteTestFile(t *testing.T, path, content string) {
t.Helper()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir %s: %v", dir, err)
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
t.Fatalf("failed to write test file %s: %v", path, err)
}
}
// MockColoredOutput captures output for testing.
type MockColoredOutput struct {
Messages []string
Errors []string
Quiet bool
}
// NewMockColoredOutput creates a new mock colored output.
func NewMockColoredOutput(quiet bool) *MockColoredOutput {
return &MockColoredOutput{Quiet: quiet}
}
// Info captures info messages.
func (m *MockColoredOutput) Info(format string, args ...any) {
if !m.Quiet {
m.Messages = append(m.Messages, fmt.Sprintf("INFO: "+format, args...))
}
}
// Success captures success messages.
func (m *MockColoredOutput) Success(format string, args ...any) {
if !m.Quiet {
m.Messages = append(m.Messages, fmt.Sprintf("SUCCESS: "+format, args...))
}
}
// Warning captures warning messages.
func (m *MockColoredOutput) Warning(format string, args ...any) {
if !m.Quiet {
m.Messages = append(m.Messages, fmt.Sprintf("WARNING: "+format, args...))
}
}
// Error captures error messages.
func (m *MockColoredOutput) Error(format string, args ...any) {
m.Errors = append(m.Errors, fmt.Sprintf("ERROR: "+format, args...))
}
// Bold captures bold messages.
func (m *MockColoredOutput) Bold(format string, args ...any) {
if !m.Quiet {
m.Messages = append(m.Messages, fmt.Sprintf("BOLD: "+format, args...))
}
}
// Printf captures printf messages.
func (m *MockColoredOutput) Printf(format string, args ...any) {
if !m.Quiet {
m.Messages = append(m.Messages, fmt.Sprintf(format, args...))
}
}
// Reset clears all captured messages.
func (m *MockColoredOutput) Reset() {
m.Messages = nil
m.Errors = nil
}
// HasMessage checks if a message contains the given substring.
func (m *MockColoredOutput) HasMessage(substring string) bool {
for _, msg := range m.Messages {
if strings.Contains(msg, substring) {
return true
}
}
return false
}
// HasError checks if an error contains the given substring.
func (m *MockColoredOutput) HasError(substring string) bool {
for _, err := range m.Errors {
if strings.Contains(err, substring) {
return true
}
}
return false
}
// CreateTestAction creates a test action.yml file content.
func CreateTestAction(name, description string, inputs map[string]string) string {
var inputsYAML bytes.Buffer
for key, desc := range inputs {
inputsYAML.WriteString(fmt.Sprintf(" %s:\n description: %s\n required: true\n", key, desc))
}
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result += "inputs:\n"
result += inputsYAML.String()
result += "outputs:\n"
result += " result:\n"
result += " description: 'The result'\n"
result += "runs:\n"
result += " using: 'node20'\n"
result += " main: 'index.js'\n"
result += "branding:\n"
result += " icon: 'zap'\n"
result += " color: 'yellow'\n"
return result
}
// SetupTestTemplates creates template files for testing.
func SetupTestTemplates(t *testing.T, dir string) {
t.Helper()
// Create templates directory structure
templatesDir := filepath.Join(dir, "templates")
themesDir := filepath.Join(templatesDir, "themes")
// Create directories
for _, theme := range []string{"github", "gitlab", "minimal", "professional"} {
themeDir := filepath.Join(themesDir, theme)
if err := os.MkdirAll(themeDir, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create theme dir %s: %v", themeDir, err)
}
// Write theme template
templatePath := filepath.Join(themeDir, "readme.tmpl")
WriteTestFile(t, templatePath, SimpleTemplate)
}
// Create default template
defaultTemplatePath := filepath.Join(templatesDir, "readme.tmpl")
WriteTestFile(t, defaultTemplatePath, SimpleTemplate)
}
// CreateCompositeAction creates a test composite action with dependencies.
func CreateCompositeAction(name, description string, steps []string) string {
var stepsYAML bytes.Buffer
for i, step := range steps {
stepsYAML.WriteString(fmt.Sprintf(" - name: Step %d\n uses: %s\n", i+1, step))
}
result := fmt.Sprintf("name: %s\n", name)
result += fmt.Sprintf("description: %s\n", description)
result += "runs:\n"
result += " using: 'composite'\n"
result += " steps:\n"
result += stepsYAML.String()
return result
}
// TestAppConfig represents a test configuration structure.
type TestAppConfig struct {
Theme string
OutputFormat string
OutputDir string
Template string
Schema string
Verbose bool
Quiet bool
GitHubToken string
}
// MockAppConfig creates a test configuration.
func MockAppConfig(overrides *TestAppConfig) *TestAppConfig {
config := &TestAppConfig{
Theme: "default",
OutputFormat: "md",
OutputDir: ".",
Template: "",
Schema: "schemas/action.schema.json",
Verbose: false,
Quiet: false,
GitHubToken: "",
}
if overrides != nil {
if overrides.Theme != "" {
config.Theme = overrides.Theme
}
if overrides.OutputFormat != "" {
config.OutputFormat = overrides.OutputFormat
}
if overrides.OutputDir != "" {
config.OutputDir = overrides.OutputDir
}
if overrides.Template != "" {
config.Template = overrides.Template
}
if overrides.Schema != "" {
config.Schema = overrides.Schema
}
config.Verbose = overrides.Verbose
config.Quiet = overrides.Quiet
if overrides.GitHubToken != "" {
config.GitHubToken = overrides.GitHubToken
}
}
return config
}
// SetEnv sets an environment variable for testing and returns cleanup function.
func SetEnv(t *testing.T, key, value string) func() {
t.Helper()
t.Setenv(key, value)
return func() {
// t.Setenv() automatically handles cleanup, so no action needed
}
}
// WithContext creates a context with timeout for testing.
func WithContext(timeout time.Duration) context.Context {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
_ = cancel // Avoid lostcancel - we're intentionally creating a context without cleanup for testing
return ctx
}
// AssertNoError fails the test if err is not nil.
func AssertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// AssertError fails the test if err is nil.
func AssertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected error but got nil")
}
}
// AssertStringContains fails the test if str doesn't contain substring.
func AssertStringContains(t *testing.T, str, substring string) {
t.Helper()
if !strings.Contains(str, substring) {
t.Fatalf("expected string to contain %q, got: %s", substring, str)
}
}
// AssertEqual fails the test if expected != actual.
func AssertEqual(t *testing.T, expected, actual any) {
t.Helper()
// Handle maps which can't be compared directly
if expectedMap, ok := expected.(map[string]string); ok {
actualMap, ok := actual.(map[string]string)
if !ok {
t.Fatalf("expected map[string]string, got %T", actual)
}
if len(expectedMap) != len(actualMap) {
t.Fatalf("expected map with %d entries, got %d", len(expectedMap), len(actualMap))
}
for k, v := range expectedMap {
if actualMap[k] != v {
t.Fatalf("expected map[%s] = %s, got %s", k, v, actualMap[k])
}
}
return
}
if expected != actual {
t.Fatalf("expected %v, got %v", expected, actual)
}
}
// NewStringReader creates an io.ReadCloser from a string.
func NewStringReader(s string) io.ReadCloser {
return io.NopCloser(strings.NewReader(s))
}
// GitHubTokenTestCase represents a test case for GitHub token hierarchy testing.
type GitHubTokenTestCase struct {
Name string
SetupFunc func(t *testing.T) func()
ExpectedToken string
}
// GetGitHubTokenHierarchyTests returns shared test cases for GitHub token hierarchy.
func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase {
return []GitHubTokenTestCase{
{
Name: "GH_README_GITHUB_TOKEN has highest priority",
SetupFunc: func(t *testing.T) func() {
t.Helper()
cleanup1 := SetEnv(t, "GH_README_GITHUB_TOKEN", "priority-token")
cleanup2 := SetEnv(t, "GITHUB_TOKEN", "fallback-token")
return func() {
cleanup1()
cleanup2()
}
},
ExpectedToken: "priority-token",
},
{
Name: "GITHUB_TOKEN as fallback",
SetupFunc: func(t *testing.T) func() {
t.Helper()
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
cleanup := SetEnv(t, "GITHUB_TOKEN", "fallback-token")
return cleanup
},
ExpectedToken: "fallback-token",
},
{
Name: "no environment variables",
SetupFunc: func(t *testing.T) func() {
t.Helper()
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
_ = os.Unsetenv("GITHUB_TOKEN")
return func() {}
},
ExpectedToken: "",
},
}
}