Module 4: Merging Branches: Integrating Test Changes

Combine test changes from different branches back into the main test suite. Learn different merge strategies, understand fast-forward vs. three-way merges, and practice merging test features developed in parallel. Handle simple merge scenarios and preview merge results before committing.

Merge Strategies and Preview Techniques

Why This Matters

As a test automation engineer, you rarely work in isolation. Your team might have multiple engineers developing different test suites simultaneouslyβ€”one person adding API tests, another building UI automation, and a third refactoring the test framework. All these changes live on separate branches and eventually need to come together in your main test suite.

The real-world challenge: How do you safely combine these parallel efforts without breaking existing tests? What happens when two engineers modify the same test configuration file? How can you preview what will happen before you merge and potentially disrupt the entire test pipeline?

When You’ll Use This Skill

  • Daily team collaboration: Integrating your completed test features back into the main branch
  • Release preparation: Combining multiple test improvements before a deployment
  • Code reviews: Merging approved test changes after peer review
  • Hotfix scenarios: Quickly integrating urgent test fixes into production branches
  • Feature integration: Bringing experimental test frameworks into the main suite

Common Pain Points Addressed

This lesson solves critical problems test engineers face:

  • “I merged and broke everything” – Learn to preview merges before executing them
  • “I don’t know which merge strategy to use” – Understand when to use fast-forward vs. three-way merges
  • “Our test history is a mess” – Choose strategies that keep your repository clean and understandable
  • “I’m afraid to merge because I might lose work” – Gain confidence with safe merge practices and preview techniques

Learning Objectives Overview

By the end of this lesson, you’ll have practical skills to manage test code integration confidently:

1. Understanding Merge Strategies

You’ll learn the fundamental difference between fast-forward and three-way merges, including when Git automatically chooses each strategy. We’ll visualize how your branch history looks after each type of merge and what that means for your test suite’s evolution.

2. Executing Merges

You’ll practice the actual commands to merge test changes from feature branches into your main branch. Using realistic test automation scenarios, you’ll merge new test files, updated configurations, and framework improvements.

3. Previewing Merge Results

Before committing to any merge, you’ll learn techniques to preview what will happenβ€”which files will change, whether conflicts exist, and what the resulting code will look like. This “look before you leap” approach prevents costly mistakes.

4. Handling Simple Merge Scenarios

You’ll work through common, straightforward merge situations that test engineers encounter daily: merging a completed feature when no one else has changed the main branch, integrating non-overlapping test suites, and combining changes to different test files.

5. Applying Appropriate Strategies

Finally, you’ll develop decision-making skills to choose the right merge strategy based on your branch’s history and team workflow. You’ll understand when to allow fast-forward merges for a linear history versus when to create explicit merge commits for better traceability.

Each objective builds on the previous one, taking you from theory to practical application, ensuring you can confidently manage test code integration in your daily work.


Core Content

Core Content: Merge Strategies and Preview Techniques

1. Core Concepts Explained

Understanding Merge Strategies

Merge strategies in test automation determine how different versions of test code, configurations, and test data are combined when multiple team members work on the same test suite. The right merge strategy ensures test stability and prevents conflicts.

Types of Merge Strategies

Fast-Forward Merge

  • Linear history preservation
  • No merge commit created
  • Works when target branch hasn’t diverged
  • Best for simple, sequential updates

Three-Way Merge

  • Creates a merge commit
  • Combines two branch histories
  • Preserves complete branch context
  • Ideal for parallel feature development

Squash Merge

  • Condenses all commits into one
  • Clean, linear history
  • Loses individual commit details
  • Perfect for feature branches with many small commits

Rebase Merge

  • Rewrites commit history
  • Creates linear progression
  • Avoids merge commits
  • Use carefully with shared branches

Preview Techniques for Test Automation

Preview techniques allow you to inspect changes before merging, ensuring test integrity and catching potential issues early.

Diff Preview

  • Compare file changes line-by-line
  • Identify test modifications
  • Review test data updates
  • Spot potential conflicts

Test Run Preview

  • Execute tests from source branch
  • Run tests from target branch
  • Compare test results
  • Validate behavior changes

Merge Simulation

  • Perform dry-run merges
  • Identify conflicts without committing
  • Analyze combined codebase
  • Verify CI/CD compatibility

2. Practical Code Examples

Setting Up a Test Repository with Merge Strategies

# Initialize a test automation repository
git init test-automation-project
cd test-automation-project

# Create initial test structure
mkdir -p tests/ui tests/api tests/integration
touch tests/ui/login.test.js tests/api/users.test.js

# Initial commit
git add .
git commit -m "Initial test structure"

Implementing Fast-Forward Merge Strategy

# Create a feature branch for new login tests
git checkout -b feature/enhanced-login-tests

# Add new test file
cat > tests/ui/login-security.test.js << 'EOF'
const { test, expect } = require('@playwright/test');

test.describe('Login Security Tests', () => {
  test('should lock account after failed attempts', async ({ page }) => {
    await page.goto('https://practiceautomatedtesting.com/login');
    
    // Attempt login 5 times with wrong password
    for (let i = 0; i < 5; i++) {
      await page.fill('#username', 'testuser');
      await page.fill('#password', 'wrongpassword');
      await page.click('#login-button');
    }
    
    // Verify account locked message
    await expect(page.locator('.error-message'))
      .toContainText('Account locked');
  });
  
  test('should enforce password complexity', async ({ page }) => {
    await page.goto('https://practiceautomatedtesting.com/register');
    
    await page.fill('#password', 'weak');
    await page.blur('#password');
    
    await expect(page.locator('.password-strength'))
      .toContainText('Password too weak');
  });
});
EOF

git add tests/ui/login-security.test.js
git commit -m "Add enhanced login security tests"

# Fast-forward merge (only if main hasn't changed)
git checkout main
git merge --ff-only feature/enhanced-login-tests

Preview Changes Before Merging

# Preview diff between branches
git diff main..feature/enhanced-login-tests

# Preview specific test files
git diff main..feature/enhanced-login-tests -- tests/ui/

# Show statistics of changes
git diff --stat main..feature/enhanced-login-tests

Three-Way Merge with Conflict Resolution

# Create two parallel feature branches
git checkout -b feature/api-tests main
cat > tests/api/products.test.js << 'EOF'
const axios = require('axios');
const { expect } = require('chai');

describe('Products API Tests', () => {
  const BASE_URL = 'https://practiceautomatedtesting.com/api';
  
  it('should retrieve all products', async () => {
    const response = await axios.get(`${BASE_URL}/products`);
    expect(response.status).to.equal(200);
    expect(response.data).to.be.an('array');
    expect(response.data.length).to.be.greaterThan(0);
  });
  
  it('should filter products by category', async () => {
    const response = await axios.get(`${BASE_URL}/products?category=electronics`);
    expect(response.status).to.equal(200);
    response.data.forEach(product => {
      expect(product.category).to.equal('electronics');
    });
  });
});
EOF

git add tests/api/products.test.js
git commit -m "Add products API tests"

# Meanwhile, another developer creates integration tests
git checkout -b feature/integration-tests main
cat > tests/integration/checkout.test.js << 'EOF'
const { test, expect } = require('@playwright/test');

test.describe('Checkout Integration Tests', () => {
  test('should complete full purchase flow', async ({ page }) => {
    await page.goto('https://practiceautomatedtesting.com');
    
    // Add product to cart
    await page.click('.product-item:first-child .add-to-cart');
    await expect(page.locator('.cart-badge')).toContainText('1');
    
    // Navigate to checkout
    await page.click('.cart-icon');
    await page.click('#checkout-button');
    
    // Fill shipping details
    await page.fill('#shipping-name', 'Test User');
    await page.fill('#shipping-address', '123 Test St');
    await page.fill('#shipping-city', 'Test City');
    
    // Complete payment
    await page.fill('#card-number', '4111111111111111');
    await page.fill('#card-expiry', '12/25');
    await page.fill('#card-cvv', '123');
    
    await page.click('#place-order');
    
    // Verify order confirmation
    await expect(page.locator('.order-confirmation'))
      .toBeVisible();
  });
});
EOF

git add tests/integration/checkout.test.js
git commit -m "Add checkout integration tests"

# Merge both features into main
git checkout main
git merge feature/api-tests
git merge feature/integration-tests  # Three-way merge

Squash Merge for Clean History

# Create feature branch with multiple commits
git checkout -b feature/ui-improvements main

# Multiple small commits
echo "// Test utilities" > tests/utils/helpers.js
git add tests/utils/helpers.js
git commit -m "Add test helpers file"

echo "export const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));" >> tests/utils/helpers.js
git commit -am "Add wait helper"

echo "export const randomString = () => Math.random().toString(36).substring(7);" >> tests/utils/helpers.js
git commit -am "Add randomString helper"

# Preview all commits that will be squashed
git log main..feature/ui-improvements --oneline

# Squash merge to consolidate commits
git checkout main
git merge --squash feature/ui-improvements
git commit -m "Add test utility helpers (consolidated)"

Advanced Preview: Simulating Merge Conflicts

# Simulate merge without committing
git checkout main
git merge --no-commit --no-ff feature/ui-improvements

# Preview the merged state
git diff --cached

# Check for conflicts
git status

# Abort if issues found
git merge --abort

# Or proceed with commit
git commit -m "Merge feature/ui-improvements"

Test Preview Automation Script

// preview-tests.js - Run tests before merging
const { execSync } = require('child_process');

function previewMerge(sourceBranch, targetBranch) {
  console.log(`\n=== Previewing merge: ${sourceBranch} β†’ ${targetBranch} ===\n`);
  
  try {
    // Save current branch
    const currentBranch = execSync('git branch --show-current').toString().trim();
    
    // Check diff statistics
    console.log('πŸ“Š Changes summary:');
    const diffStat = execSync(`git diff --stat ${targetBranch}..${sourceBranch}`).toString();
    console.log(diffStat);
    
    // Simulate merge
    console.log('\nπŸ”„ Simulating merge...');
    execSync(`git checkout ${targetBranch}`);
    execSync(`git merge --no-commit --no-ff ${sourceBranch}`, { stdio: 'inherit' });
    
    // Run tests on merged code
    console.log('\nπŸ§ͺ Running tests on merged code...');
    const testResult = execSync('npm test', { stdio: 'inherit' });
    
    // Abort merge
    execSync('git merge --abort');
    
    // Return to original branch
    execSync(`git checkout ${currentBranch}`);
    
    console.log('\nβœ… Merge preview successful! Safe to merge.');
    return true;
    
  } catch (error) {
    console.error('\n❌ Merge preview failed!');
    
    // Cleanup
    execSync('git merge --abort');
    execSync(`git checkout ${currentBranch}`);
    
    return false;
  }
}

// Usage
previewMerge('feature/enhanced-login-tests', 'main');

Configuration File for Merge Strategy

# .github/merge-config.yml
merge:
  strategy: squash  # Options: merge, squash, rebase
  
  preview:
    required: true
    run_tests: true
    require_approval: 2
    
  rules:
    - pattern: "tests/**"
      strategy: merge  # Preserve test history
      reviewers: ["qa-team"]
      
    - pattern: "docs/**"
      strategy: squash  # Clean doc updates
      
    - pattern: "package.json"
      strategy: merge
      require_manual_review: true

  auto_merge:
    enabled: true
    conditions:
      - all_tests_pass
      - approvals >= 2
      - no_conflicts

Playwright Configuration with Merge Preview

// playwright.config.js - Configuration for merge previews
module.exports = {
  testDir: './tests',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  
  // Run different test suites based on branch
  projects: [
    {
      name: 'main-branch',
      testMatch: '**/*.test.js',
      use: {
        baseURL: 'https://practiceautomatedtesting.com',
        screenshot: 'only-on-failure',
        video: 'retain-on-failure'
      }
    },
    {
      name: 'feature-preview',
      testMatch: '**/*.test.js',
      use: {
        baseURL: process.env.PREVIEW_URL || 'https://practiceautomatedtesting.com',
        screenshot: 'on',  // Capture all for comparison
        video: 'on'
      }
    }
  ],
  
  // Reporter for merge preview comparison
  reporter: [
    ['html', { outputFolder: 'test-results-preview' }],
    ['json', { outputFile: 'test-results-preview.json' }]
  ]
};

3. Common Mistakes Section

Mistake 1: Merging Without Preview

Problem: Directly merging branches without reviewing changes can introduce breaking tests.

# ❌ Wrong - Direct merge without review
git checkout main
git merge feature/new-tests

# βœ… Correct - Preview first
git diff main..feature/new-tests
git log main..feature/new-tests --oneline
npm test  # Run tests before merging
git merge feature/new-tests

Mistake 2: Using Wrong Merge Strategy

Problem: Using fast-forward when you need to preserve branch history.

# ❌ Wrong - Loses feature context
git merge --ff-only feature/major-refactor

# βœ… Correct - Preserve merge history for major changes
git merge --no-ff feature/major-refactor -m "Merge major test refactor"

Mistake 3: Not Handling Test Conflicts Properly

Problem: Auto-resolving test conflicts can break test logic.

# When conflicts occur in tests:
# ❌ Wrong - Using 'ours' or 'theirs' blindly
git checkout --ours tests/critical.test.js

# βœ… Correct - Manual review and test execution
git diff
# Manually resolve conflicts
npm test  # Verify tests still work
git add tests/critical.test.js
git commit

Mistake 4: Forgetting to Update Test Dependencies

Problem: Merging code without ensuring test dependencies align.

# βœ… Correct approach - Check dependencies before merge
git diff main..feature/new-tests -- package.json
npm install  # Update dependencies
npm test  # Verify compatibility
git merge feature/new-tests

Debugging Merge Issues

# View merge conflicts
git status
git diff

# See which commits conflict
git log --merge

# Use merge tool for visual resolution
git mergetool

# Check test files specifically
git diff --name-only --diff-filter=U | grep test

# Abort and retry if needed
git merge --abort

Hands-On Practice

EXERCISE AND CONCLUSION

🎯 Hands-On Exercise

Task: Implement a Multi-Strategy Merge Preview System

You’ll build a test automation system that previews and executes different merge strategies for conflicting test configurations. This exercise tests your ability to handle merge conflicts, preview changes, and apply appropriate strategies.

Scenario

Your team maintains test configurations across multiple environments (dev, staging, prod). When configurations conflict, you need to preview the merge results before applying them to ensure test stability.

Instructions

Step 1: Set Up Base Configuration Classes

Create a configuration merge system with preview capabilities:

# starter_code.py
from enum import Enum
from typing import Dict, List, Any
from copy import deepcopy

class MergeStrategy(Enum):
    OURS = "ours"
    THEIRS = "theirs"
    UNION = "union"
    MANUAL = "manual"

class ConfigMerger:
    def __init__(self):
        self.conflicts = []
        self.preview = {}
    
    # TODO: Implement merge_configs method
    def merge_configs(self, base: Dict, incoming: Dict, strategy: MergeStrategy) -> Dict:
        pass
    
    # TODO: Implement preview_merge method
    def preview_merge(self, base: Dict, incoming: Dict, strategy: MergeStrategy) -> Dict:
        pass
    
    # TODO: Implement detect_conflicts method
    def detect_conflicts(self, base: Dict, incoming: Dict) -> List[str]:
        pass

Step 2: Implement Merge Strategies

Complete the ConfigMerger class with the following merge strategies:

  • OURS: Keep base configuration values on conflict
  • THEIRS: Accept incoming configuration values on conflict
  • UNION: Combine lists/sets, prefer incoming for primitives
  • MANUAL: Raise exception requiring user intervention

Step 3: Add Preview Functionality

Implement a preview system that:

  • Shows what will change without applying changes
  • Highlights conflicts
  • Displays diff between current and proposed state

Step 4: Create Test Suite

Write tests covering:

  • Each merge strategy with conflicting configs
  • Preview generation without side effects
  • Conflict detection accuracy
  • Edge cases (nested configs, null values, type mismatches)

Expected Outcome

Your implementation should:

  1. βœ… Successfully merge configurations using all four strategies
  2. βœ… Generate accurate previews without modifying original data
  3. βœ… Detect and report all conflicts
  4. βœ… Pass all test cases including edge cases

Solution Approach

Click to view solution approach
# solution.py
from enum import Enum
from typing import Dict, List, Any, Tuple
from copy import deepcopy
import json

class MergeStrategy(Enum):
    OURS = "ours"
    THEIRS = "theirs"
    UNION = "union"
    MANUAL = "manual"

class MergeConflict(Exception):
    pass

class ConfigMerger:
    def __init__(self):
        self.conflicts = []
        self.preview = {}
    
    def merge_configs(self, base: Dict, incoming: Dict, strategy: MergeStrategy) -> Dict:
        """Merge two configurations using specified strategy"""
        self.conflicts = []
        result = deepcopy(base)
        
        self._merge_recursive(result, incoming, strategy, path="")
        
        if strategy == MergeStrategy.MANUAL and self.conflicts:
            raise MergeConflict(f"Manual resolution required for: {self.conflicts}")
        
        return result
    
    def preview_merge(self, base: Dict, incoming: Dict, strategy: MergeStrategy) -> Dict:
        """Preview merge without modifying original data"""
        preview_data = {
            "strategy": strategy.value,
            "conflicts": [],
            "changes": [],
            "result": None
        }
        
        try:
            result = self.merge_configs(deepcopy(base), incoming, strategy)
            preview_data["result"] = result
            preview_data["conflicts"] = self.conflicts
            preview_data["changes"] = self._generate_diff(base, result)
        except MergeConflict as e:
            preview_data["conflicts"] = self.conflicts
            preview_data["error"] = str(e)
        
        return preview_data
    
    def detect_conflicts(self, base: Dict, incoming: Dict) -> List[str]:
        """Detect conflicting keys between configurations"""
        conflicts = []
        self._detect_conflicts_recursive(base, incoming, conflicts, path="")
        return conflicts
    
    def _merge_recursive(self, base: Any, incoming: Any, strategy: MergeStrategy, path: str):
        """Recursively merge nested structures"""
        if not isinstance(incoming, dict):
            return
        
        for key, incoming_value in incoming.items():
            current_path = f"{path}.{key}" if path else key
            
            if key not in base:
                base[key] = deepcopy(incoming_value)
            elif base[key] != incoming_value:
                # Conflict detected
                self.conflicts.append(current_path)
                
                if isinstance(base[key], dict) and isinstance(incoming_value, dict):
                    self._merge_recursive(base[key], incoming_value, strategy, current_path)
                elif isinstance(base[key], list) and isinstance(incoming_value, list):
                    if strategy == MergeStrategy.UNION:
                        base[key] = list(set(base[key] + incoming_value))
                    elif strategy == MergeStrategy.THEIRS:
                        base[key] = deepcopy(incoming_value)
                    # OURS keeps base[key] as is
                else:
                    # Primitive value conflict
                    if strategy == MergeStrategy.THEIRS or strategy == MergeStrategy.UNION:
                        base[key] = deepcopy(incoming_value)
                    elif strategy == MergeStrategy.OURS:
                        pass  # Keep base value
                    # MANUAL will raise exception after collection
    
    def _detect_conflicts_recursive(self, base: Any, incoming: Any, conflicts: List, path: str):
        """Recursively detect conflicts"""
        if not isinstance(base, dict) or not isinstance(incoming, dict):
            return
        
        for key in incoming:
            current_path = f"{path}.{key}" if path else key
            if key in base and base[key] != incoming[key]:
                conflicts.append(current_path)
                if isinstance(base[key], dict) and isinstance(incoming[key], dict):
                    self._detect_conflicts_recursive(base[key], incoming[key], conflicts, current_path)
    
    def _generate_diff(self, old: Dict, new: Dict) -> List[Dict]:
        """Generate diff between old and new configurations"""
        changes = []
        all_keys = set(old.keys()) | set(new.keys())
        
        for key in all_keys:
            if key not in old:
                changes.append({"key": key, "type": "added", "value": new[key]})
            elif key not in new:
                changes.append({"key": key, "type": "removed", "value": old[key]})
            elif old[key] != new[key]:
                changes.append({"key": key, "type": "modified", "old": old[key], "new": new[key]})
        
        return changes


# Test Suite
import pytest

def test_ours_strategy():
    merger = ConfigMerger()
    base = {"timeout": 30, "retries": 3}
    incoming = {"timeout": 60, "retries": 5}
    
    result = merger.merge_configs(base, incoming, MergeStrategy.OURS)
    assert result["timeout"] == 30
    assert result["retries"] == 3

def test_theirs_strategy():
    merger = ConfigMerger()
    base = {"timeout": 30, "retries": 3}
    incoming = {"timeout": 60, "retries": 5}
    
    result = merger.merge_configs(base, incoming, MergeStrategy.THEIRS)
    assert result["timeout"] == 60
    assert result["retries"] == 5

def test_union_strategy_with_lists():
    merger = ConfigMerger()
    base = {"browsers": ["chrome", "firefox"]}
    incoming = {"browsers": ["firefox", "safari"]}
    
    result = merger.merge_configs(base, incoming, MergeStrategy.UNION)
    assert set(result["browsers"]) == {"chrome", "firefox", "safari"}

def test_manual_strategy_raises_on_conflict():
    merger = ConfigMerger()
    base = {"timeout": 30}
    incoming = {"timeout": 60}
    
    with pytest.raises(MergeConflict):
        merger.merge_configs(base, incoming, MergeStrategy.MANUAL)

def test_preview_has_no_side_effects():
    merger = ConfigMerger()
    base = {"timeout": 30}
    incoming = {"timeout": 60}
    
    original_base = deepcopy(base)
    preview = merger.preview_merge(base, incoming, MergeStrategy.THEIRS)
    
    assert base == original_base
    assert preview["result"]["timeout"] == 60

def test_conflict_detection():
    merger = ConfigMerger()
    base = {"timeout": 30, "retries": 3, "env": "dev"}
    incoming = {"timeout": 60, "retries": 3, "env": "prod"}
    
    conflicts = merger.detect_conflicts(base, incoming)
    assert "timeout" in conflicts
    assert "env" in conflicts
    assert "retries" not in conflicts

def test_nested_config_merge():
    merger = ConfigMerger()
    base = {"database": {"host": "localhost", "port": 5432}}
    incoming = {"database": {"host": "prod-db", "ssl": True}}
    
    result = merger.merge_configs(base, incoming, MergeStrategy.UNION)
    assert result["database"]["host"] == "prod-db"
    assert result["database"]["port"] == 5432
    assert result["database"]["ssl"] == True

πŸ“š Key Takeaways

  • Merge strategies provide flexibility: Different situations require different approachesβ€”use OURS for stability, THEIRS for updates, UNION for combining, and MANUAL for critical decisions requiring human review.

  • Preview before executing: Always generate previews of merge operations to understand the impact before applying changes, especially in production test environments. Previews should never modify source data.

  • Conflict detection is essential: Automatically identifying conflicts between configurations prevents unexpected test failures and helps maintain consistency across environments.

  • Deep copying prevents side effects: When implementing merge and preview operations, always work with copies of data structures to ensure original configurations remain unchanged until explicitly committed.

  • Strategy patterns enhance maintainability: Implementing multiple merge strategies using enums and polymorphic behavior creates clean, testable, and extensible automation code.


πŸš€ Next Steps

Practice These Skills

  1. Extend merge strategies: Add a “SMART” strategy that automatically chooses the best approach based on data types and context
  2. Implement rollback functionality: Create a system to undo merges and restore previous configurations
  3. Add merge history tracking: Build an audit trail showing who merged what, when, and using which strategy
  4. Create conflict resolution UI: Design an interactive tool for manual conflict resolution
  • Version Control Integration: Integrate your merger with Git to handle test configuration branches
  • Schema Validation: Add configuration schema validation before and after merges
  • Distributed Configuration Management: Scale to handle configuration merges across multiple test environments simultaneously
  • Merge Optimization: Implement algorithms to minimize conflicts through intelligent key grouping
  • Configuration as Code: Explore tools like Terraform, Ansible, or Kubernetes ConfigMaps for infrastructure-level merge strategies
  • Martin Fowler’s “Continuous Integration” patterns for configuration management
  • “Git Internals” documentation for