Skip to content

Latest commit

 

History

History
526 lines (407 loc) · 20 KB

File metadata and controls

526 lines (407 loc) · 20 KB

Testing Guidelines

This document outlines the testing conventions and patterns used in the git-flow-next project.

Important: When implementing tests, you MUST read GIT_TEST_SCENARIOS.md for detailed instructions on setting up Git test scenarios (merge conflicts, remotes, state verification). That document contains essential patterns for creating realistic test conditions.

Test Function Naming

Follow descriptive naming patterns that clearly indicate what is being tested:

// Basic functionality
func TestStartFeatureBranch(t *testing.T)
func TestFinishFeatureBranch(t *testing.T)
func TestInitWithDefaults(t *testing.T)

// Configuration-specific tests
func TestStartWithCustomConfig(t *testing.T)
func TestInitWithAVHConfig(t *testing.T)

// Error conditions
func TestUpdateWithMergeConflict(t *testing.T)
func TestFinishWithMergeConflict(t *testing.T)
func TestDeleteNonExistentRemoteBranch(t *testing.T)

// Feature-specific tests
func TestFinishFeatureBranchWithFetchFlag(t *testing.T)
func TestStartWithFetchFlag(t *testing.T)

Test Comments and State Documentation

REQUIRED: Every test function must have a structured comment that follows this exact pattern:

  1. First line: Brief description of what the test validates
  2. Steps: section with numbered list of test actions
  3. Expected outcomes embedded in the steps

Comment Pattern (MANDATORY)

// TestFinishWithMergeConflict tests the behavior when finishing a branch with merge conflicts.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Creates a feature branch
// 3. Adds conflicting changes to both feature and develop branches
// 4. Attempts to finish the feature branch
// 5. Verifies the operation fails with merge conflict
func TestFinishWithMergeConflict(t *testing.T) {
    // Test implementation...
}

Additional Comment Guidelines

  • Be specific: Include exact branch names, commands, and expected results
  • Number all steps: Use sequential numbering (1, 2, 3...)
  • Include verification: Always specify what is being verified
  • Use active voice: "Creates a branch" not "A branch is created"
  • Match test structure: Comments should reflect actual test implementation

Examples of Good Test Comments

// TestConfigAddBase tests adding base branch configurations.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Adds various base branches with different configurations  
// 3. Verifies branches are created and configuration is saved correctly
// 4. Tests error conditions like duplicate branches and invalid parents
func TestConfigAddBase(t *testing.T) { ... }

// TestStartFeatureBranch tests the start command for feature branches.
// Steps:
// 1. Sets up a test repository and initializes git-flow with defaults
// 2. Runs 'git flow feature start my-feature'
// 3. Verifies feature/my-feature branch is created
// 4. Verifies branch is based on develop branch
func TestStartFeatureBranch(t *testing.T) { ... }

One Test Case Per Function Rule

CRITICAL: Each test function must test exactly one scenario or behavior. Never use table-driven tests with multiple test cases in a single function.

❌ BAD: Multiple Test Cases in One Function

func TestMergeStrategyConfigHierarchy(t *testing.T) {
    testCases := []struct {
        name           string
        branchConfig   string
        commandConfig  string
        flag           string
        expectedResult string
    }{
        {"branch_default_merge", "merge", "", "", "merge"},
        {"command_overrides_branch", "merge", "rebase=true", "", "rebase"},
        {"flag_overrides_config", "merge", "rebase=true", "--no-rebase", "merge"},
    }
    
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // Test implementation...
        })
    }
}

✅ GOOD: One Test Case Per Function

// TestMergeStrategyBranchDefault tests that branch default strategy is used.
// Steps:
// 1. Sets up repository with branch configured for merge strategy
// 2. Runs finish command without overrides
// 3. Verifies merge strategy was used
func TestMergeStrategyBranchDefault(t *testing.T) {
    // Single test scenario implementation...
}

// TestMergeStrategyCommandConfigOverride tests command config overriding branch default.
// Steps:
// 1. Sets up repository with branch configured for merge strategy
// 2. Sets gitflow.feature.finish.rebase=true in config
// 3. Runs finish command without flags
// 4. Verifies rebase strategy was used instead of merge
func TestMergeStrategyCommandConfigOverride(t *testing.T) {
    // Single test scenario implementation...
}

// TestMergeStrategyFlagOverridesConfig tests command flag overriding config.
// Steps:
// 1. Sets up repository with rebase configured via command config
// 2. Runs finish command with --no-rebase flag
// 3. Verifies merge strategy was used instead of rebase
func TestMergeStrategyFlagOverridesConfig(t *testing.T) {
    // Single test scenario implementation...
}

Why One Test Case Per Function?

  1. Clear failure identification - When a test fails, you immediately know which specific scenario failed
  2. Focused debugging - Each test tests one thing, making debugging straightforward
  3. Maintainable test suite - Easy to modify, disable, or extend individual test scenarios
  4. Better test names - Function names clearly describe what is being tested
  5. Proper test isolation - Each test sets up exactly what it needs, nothing more
  6. Follows git-flow-next philosophy - Explicit and readable over clever and compact

Exception: Helper Functions

Table-driven tests are acceptable in helper functions that test pure functions with multiple input/output combinations:

func TestValidateBranchName(t *testing.T) {
    // This is acceptable for pure validation functions
    testCases := []struct {
        name    string
        input   string
        isValid bool
    }{
        {"valid_name", "feature-branch", true},
        {"invalid_spaces", "feature branch", false},
        {"invalid_colon", "feature:branch", false},
    }
    
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            result := util.ValidateBranchName(tc.input)
            assert.Equal(t, tc.isValid, result == nil)
        })
    }
}

Rule: Only use table-driven tests for testing pure functions with simple input/output validation, never for complex integration scenarios.

Temporary Git Repository Testing

All tests use temporary Git repositories created through test utilities.

Default Branch Configuration

When using git flow init --defaults, the following branches and settings are configured:

Base Branches

  • main: Production branch (root branch)
  • develop: Integration branch (auto-updates from main)

Topic Branch Types

  • feature: Prefix feature/, parent develop, starts from develop
  • release: Prefix release/, parent main, starts from develop
  • hotfix: Prefix hotfix/, parent main, starts from main

Default Merge Strategies

  • Feature finish: merge into develop
  • Release finish: merge into main (then auto-update develop)
  • Hotfix finish: merge into main (then auto-update develop)
  • Feature update: rebase from develop
  • Release update: merge from main
  • Hotfix update: rebase from main

Default Tag Settings

  • Feature: No tags created
  • Release: Tags created on finish
  • Hotfix: Tags created on finish

Testing with Default Configuration

  • Use git-flow defaults by default: Initialize repositories with git flow init --defaults
  • Use feature branches for topic branch testing: Feature branches are the most common topic branch type and should be used for general topic branch functionality tests
  • Only create standalone branches when required: If your test specifically needs a non-git-flow branch or custom configuration, create it explicitly
  • Adjust configuration when needed: Use git config commands to modify behavior for specific test scenarios
// Standard test setup - use this for most tests
func TestExample(t *testing.T) {
    dir := testutil.SetupTestRepo(t)
    defer testutil.CleanupTestRepo(t, dir)
    
    // Initialize with defaults - creates main, develop, and configures feature/release/hotfix
    output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
    if err != nil {
        t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
    }
    
    // Use feature branches for topic branch testing
    output, err = testutil.RunGitFlow(t, dir, "feature", "start", "test-branch")
    // ... test implementation
}

// Example: Testing with modified merge strategy
func TestFeatureFinishWithRebase(t *testing.T) {
    dir := testutil.SetupTestRepo(t)
    defer testutil.CleanupTestRepo(t, dir)
    
    // Initialize with defaults
    output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
    if err != nil {
        t.Fatalf("Failed to initialize git-flow: %v", err)
    }
    
    // Modify merge strategy for this test
    _, err = testutil.RunGit(t, dir, "config", "gitflow.feature.finish.merge", "rebase")
    if err != nil {
        t.Fatalf("Failed to configure rebase strategy: %v", err)
    }
    
    // Now test with the modified configuration
    output, err = testutil.RunGitFlow(t, dir, "feature", "start", "rebase-test")
    // ... test implementation
}

Basic Test Setup

func TestExample(t *testing.T) {
    // Setup temporary repository
    dir := testutil.SetupTestRepo(t)
    defer testutil.CleanupTestRepo(t, dir)
    
    // Initialize git-flow with defaults
    output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
    if err != nil {
        t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
    }
    
    // Test implementation...
}

Git Test Helper Functions

Available in test/testutil/git.go:

  • SetupTestRepo(t *testing.T) string - Creates temporary Git repository
  • CleanupTestRepo(t *testing.T, dir string) - Removes temporary repository
  • RunGit(t *testing.T, dir string, args ...string) - Executes Git commands
  • RunGitFlow(t *testing.T, dir string, args ...string) - Executes git-flow commands
  • WriteFile(t *testing.T, dir string, name string, content string) - Creates files
  • BranchExists(t *testing.T, dir string, branch string) bool - Checks branch existence
  • GetCurrentBranch(t *testing.T, dir string) string - Gets current branch name
  • AddRemote(t *testing.T, dir, name string, bare bool) (string, error) - Adds a remote repository
  • SetupTestRepoWithRemote(t *testing.T) (string, string) - Creates repo with local remote

For detailed examples of creating merge conflicts, setting up remotes, and verifying Git states, see GIT_TEST_SCENARIOS.md.

Test Organization

Directory Structure

test/
├── cmd/              # Command-level integration tests
├── internal/         # Internal package unit tests
└── testutil/         # Test utilities and helpers

Error Handling in Tests

Always include comprehensive error checking with detailed failure messages:

output, err := testutil.RunGitFlow(t, dir, "feature", "start", "test-branch")
if err != nil {
    t.Fatalf("Failed to start feature branch: %v\nOutput: %s", err, output)
}

State Verification

Verify both Git state and application-specific state:

// Check Git state
if !testutil.BranchExists(t, dir, "feature/test-branch") {
    t.Error("Expected feature branch to exist")
}

// Check application state
state, err := testutil.LoadMergeState(t, dir)
if state.Action != "finish" {
    t.Errorf("Expected action to be 'finish', got '%s'", state.Action)
}

Best Practices

  1. Always use testutil helpers - Never execute Git commands directly
  2. Avoid internal package imports in cmd tests - Don't import internal/git or internal/config in command tests; use testutil functions instead (see Working Directory Management)
  3. Use long flag variants only - Don't test both -f and --force; testing one variant is sufficient since Cobra handles flag parsing. Always use the long form (--force, --defaults) in tests for readability
  4. Include setup/cleanup - Use defer to ensure cleanup happens
  5. Test error conditions - Verify failures behave correctly
  6. Check intermediate state - Don't just test final outcomes
  7. Use descriptive assertions - Include context in error messages
  8. Match test setup to test goal - If testing behavior X, don't create dependencies on unrelated feature Y
  9. Test with remotes when relevant - Many Git operations behave differently with remotes
  10. Verify test helper implementations - Don't trust placeholder functions that may not actually call commands
  11. Create conflicts correctly - Branch first, then add conflicting content (see GIT_TEST_SCENARIOS.md)
  12. Use Git internal state for verification - Check .git/ directory contents for reliable conflict state detection

Working Directory Management (CRITICAL)

⚠️ IMPORTANT: Test commands must run in the test repository directory, not the directory where tests are executed from.

The Problem

When executing Git or git-flow commands in tests, the commands run in the current working directory by default. If you don't explicitly set the working directory, commands will execute in the test runner's directory (e.g., test/cmd/) instead of your temporary test repository. This causes:

  • Commands operating on the wrong repository
  • Flaky tests that depend on global state
  • Tests that pass locally but fail in CI
  • Hard-to-debug failures

The Solution: Always Use cmd.Dir

The testutil helper functions (RunGit, RunGitFlow, RunGitFlowWithInput) properly set cmd.Dir to ensure commands execute in the correct directory:

// From test/testutil/git.go - CORRECT pattern
func RunGit(t *testing.T, dir string, args ...string) (string, error) {
    cmd := exec.Command("git", args...)
    cmd.Dir = dir  // ✓ Ensures command runs in test repo
    // ...
}

✅ CORRECT: Use testutil Functions

func TestExample(t *testing.T) {
    dir := testutil.SetupTestRepo(t)
    defer testutil.CleanupTestRepo(t, dir)

    // CORRECT: testutil.RunGit sets cmd.Dir internally
    output, err := testutil.RunGit(t, dir, "checkout", "-b", "feature/test")
    if err != nil {
        t.Fatalf("Failed to create branch: %v", err)
    }

    // CORRECT: testutil.RunGitFlow also sets cmd.Dir
    output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "test")
    // ...
}

❌ AVOID: Using os.Chdir() with Internal Functions

Some tests use os.Chdir() to change the working directory, then call internal git functions that don't accept directory parameters. This pattern is fragile:

// PROBLEMATIC: Relies on global state
func TestExample(t *testing.T) {
    dir := testutil.SetupTestRepo(t)
    defer testutil.CleanupTestRepo(t, dir)

    originalDir, _ := os.Getwd()
    defer os.Chdir(originalDir)  // Must restore!

    if err := os.Chdir(dir); err != nil {
        t.Fatal(err)
    }

    // These internal functions have no dir parameter
    // They rely on os.Chdir() having been called
    if err := git.Checkout("develop"); err != nil {  // ⚠️ Uses current dir
        t.Fatal(err)
    }
}

Why this is problematic:

  • If os.Chdir() fails or defer doesn't run, subsequent tests break
  • Test parallelization becomes impossible (shared global state)
  • Harder to understand which directory commands execute in

When os.Chdir() Is Acceptable

In some cases, os.Chdir() is necessary because internal functions (internal/git/repo.go, internal/git/config.go) don't accept directory parameters. If you must use this pattern:

  1. Always save and restore the original directory with defer
  2. Place defer immediately after the os.Chdir() call
  3. Document why the pattern is necessary
  4. Consider adding *InDir variants to internal functions if this pattern repeats
// If os.Chdir() is unavoidable, do it safely:
originalDir, err := os.Getwd()
if err != nil {
    t.Fatalf("Failed to get working directory: %v", err)
}
if err := os.Chdir(dir); err != nil {
    t.Fatalf("Failed to change to test directory: %v", err)
}
defer func() {
    if err := os.Chdir(originalDir); err != nil {
        t.Errorf("Failed to restore working directory: %v", err)
    }
}()

Known Areas Using os.Chdir()

The following test files currently use os.Chdir() because they call internal functions without directory parameters:

  • test/cmd/config_test.go - Calls config.LoadConfig(), etc.
  • test/cmd/init_test.go - Some edge case tests

Note: Functions in internal/git/repo.go and internal/git/config.go don't accept directory parameters. If you need to call these in tests, you must use os.Chdir(). Consider whether a testutil helper or *InDir variant would be better.

Refactored: test/cmd/update_test.go was refactored to use only testutil functions, eliminating the need for os.Chdir() and internal package imports.

Test Implementation Anti-Patterns

Common testing mistakes to avoid:

  1. Trusting placeholder functions - Ensure helper functions actually call the commands being tested
  2. Wrong conflict generation sequence - Create branches before adding conflicting content
  3. Incorrect rebase state verification - Check .git/rebase-merge/ instead of branch name
  4. Testing multiple behaviors with fragile dependencies - Keep tests focused on their stated purpose

For detailed examples of these anti-patterns and their solutions, see GIT_TEST_SCENARIOS.md.

Test Execution Methods

CRITICAL: Use Test Helpers, Not Bash Piping

IMPORTANT: When running git-flow commands that require interactive input, always use the runGitFlowWithInput helper function instead of bash piping.

❌ BAD: Using Bash Piping

// BAD: Bash piping can cause flaky tests due to process isolation issues
cmd := exec.Command("bash", "-c", fmt.Sprintf("cat %s | %s init", scriptPath, gitFlowPath))
cmd.Dir = dir
err = cmd.Run()

Problems with bash piping:

  1. Creates an extra shell process layer that can affect working directory inheritance
  2. May cause intermittent failures that are hard to reproduce
  3. Harder to debug when failures occur

✅ GOOD: Using Test Helper

// GOOD: Direct stdin piping through the test helper
input := "custom-main\ncustom-dev\nfeature/\nbugfix/\nrelease/\nhotfix/\nsupport/\nv\n"
output, err := runGitFlowWithInput(t, dir, input, "init")

Benefits:

  1. Direct stdin connection to the git-flow process
  2. Consistent working directory handling via cmd.Dir
  3. Reliable and deterministic test execution

Working Directory Handling

All test helper functions must set cmd.Dir to the test repository directory:

func runGitFlow(t *testing.T, dir string, args ...string) (string, error) {
    gitFlowPath, _ := filepath.Abs(filepath.Join("..", "..", "git-flow"))
    cmd := exec.Command(gitFlowPath, args...)
    cmd.Dir = dir  // CRITICAL: Always set working directory
    // ...
}

Why this matters:

  • Git commands run by the git-flow binary inherit the working directory
  • Without proper cmd.Dir setting, commands may run in the wrong repository
  • This can cause tests to pass locally but fail in CI, or vice versa

Debugging Test Failures

When tests fail, follow this systematic approach:

  1. Check Helper Function Implementations - Ensure they actually call the intended commands
  2. Verify Conflict Setup - Confirm branches and files are created in correct sequence
  3. Inspect Git State - Use .git/ directory contents to understand actual repository state
  4. Test Configuration Persistence - Verify that config changes are actually saved and old entries removed
  5. Check Command Flag Logic - Ensure flag detection properly influences command behavior
  6. Verify Test Execution Method - Ensure tests use runGitFlowWithInput instead of bash piping for interactive commands