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.
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)REQUIRED: Every test function must have a structured comment that follows this exact pattern:
- First line: Brief description of what the test validates
- Steps: section with numbered list of test actions
- Expected outcomes embedded in the steps
// 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...
}- 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
// 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) { ... }CRITICAL: Each test function must test exactly one scenario or behavior. Never use table-driven tests with multiple test cases in a single 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...
})
}
}// 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...
}- Clear failure identification - When a test fails, you immediately know which specific scenario failed
- Focused debugging - Each test tests one thing, making debugging straightforward
- Maintainable test suite - Easy to modify, disable, or extend individual test scenarios
- Better test names - Function names clearly describe what is being tested
- Proper test isolation - Each test sets up exactly what it needs, nothing more
- Follows git-flow-next philosophy - Explicit and readable over clever and compact
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.
All tests use temporary Git repositories created through test utilities.
When using git flow init --defaults, the following branches and settings are configured:
- main: Production branch (root branch)
- develop: Integration branch (auto-updates from main)
- feature: Prefix
feature/, parentdevelop, starts fromdevelop - release: Prefix
release/, parentmain, starts fromdevelop - hotfix: Prefix
hotfix/, parentmain, starts frommain
- Feature finish:
mergeintodevelop - Release finish:
mergeintomain(then auto-updatedevelop) - Hotfix finish:
mergeintomain(then auto-updatedevelop) - Feature update:
rebasefromdevelop - Release update:
mergefrommain - Hotfix update:
rebasefrommain
- Feature: No tags created
- Release: Tags created on finish
- Hotfix: Tags created on finish
- 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 configcommands 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
}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...
}Available in test/testutil/git.go:
SetupTestRepo(t *testing.T) string- Creates temporary Git repositoryCleanupTestRepo(t *testing.T, dir string)- Removes temporary repositoryRunGit(t *testing.T, dir string, args ...string)- Executes Git commandsRunGitFlow(t *testing.T, dir string, args ...string)- Executes git-flow commandsWriteFile(t *testing.T, dir string, name string, content string)- Creates filesBranchExists(t *testing.T, dir string, branch string) bool- Checks branch existenceGetCurrentBranch(t *testing.T, dir string) string- Gets current branch nameAddRemote(t *testing.T, dir, name string, bare bool) (string, error)- Adds a remote repositorySetupTestRepoWithRemote(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/
├── cmd/ # Command-level integration tests
├── internal/ # Internal package unit tests
└── testutil/ # Test utilities and helpers
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)
}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)
}- Always use testutil helpers - Never execute Git commands directly
- Avoid internal package imports in cmd tests - Don't import
internal/gitorinternal/configin command tests; use testutil functions instead (see Working Directory Management) - Use long flag variants only - Don't test both
-fand--force; testing one variant is sufficient since Cobra handles flag parsing. Always use the long form (--force,--defaults) in tests for readability - Include setup/cleanup - Use defer to ensure cleanup happens
- Test error conditions - Verify failures behave correctly
- Check intermediate state - Don't just test final outcomes
- Use descriptive assertions - Include context in error messages
- Match test setup to test goal - If testing behavior X, don't create dependencies on unrelated feature Y
- Test with remotes when relevant - Many Git operations behave differently with remotes
- Verify test helper implementations - Don't trust placeholder functions that may not actually call commands
- Create conflicts correctly - Branch first, then add conflicting content (see GIT_TEST_SCENARIOS.md)
- Use Git internal state for verification - Check
.git/directory contents for reliable conflict state detection
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 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
// ...
}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")
// ...
}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
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:
- Always save and restore the original directory with defer
- Place defer immediately after the
os.Chdir()call - Document why the pattern is necessary
- Consider adding
*InDirvariants 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)
}
}()The following test files currently use os.Chdir() because they call internal functions without directory parameters:
test/cmd/config_test.go- Callsconfig.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.
Common testing mistakes to avoid:
- Trusting placeholder functions - Ensure helper functions actually call the commands being tested
- Wrong conflict generation sequence - Create branches before adding conflicting content
- Incorrect rebase state verification - Check
.git/rebase-merge/instead of branch name - 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.
IMPORTANT: When running git-flow commands that require interactive input, always use the runGitFlowWithInput helper function instead of 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:
- Creates an extra shell process layer that can affect working directory inheritance
- May cause intermittent failures that are hard to reproduce
- Harder to debug when failures occur
// 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:
- Direct stdin connection to the git-flow process
- Consistent working directory handling via
cmd.Dir - Reliable and deterministic test execution
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.Dirsetting, commands may run in the wrong repository - This can cause tests to pass locally but fail in CI, or vice versa
When tests fail, follow this systematic approach:
- Check Helper Function Implementations - Ensure they actually call the intended commands
- Verify Conflict Setup - Confirm branches and files are created in correct sequence
- Inspect Git State - Use
.git/directory contents to understand actual repository state - Test Configuration Persistence - Verify that config changes are actually saved and old entries removed
- Check Command Flag Logic - Ensure flag detection properly influences command behavior
- Verify Test Execution Method - Ensure tests use
runGitFlowWithInputinstead of bash piping for interactive commands